From 155364fa772485e2c9af59fd2581aa44b1acc60f Mon Sep 17 00:00:00 2001 From: Sumega Mandadi Date: Mon, 1 Dec 2025 12:00:34 -1000 Subject: [PATCH 1/8] update preprocess function to work with radiometric data --- heatseek/boson_capture.py | 2 +- heatseek/cli.py | 15 ++++++++- heatseek/preprocess.py | 70 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/heatseek/boson_capture.py b/heatseek/boson_capture.py index b959fe5..4c7b4e3 100644 --- a/heatseek/boson_capture.py +++ b/heatseek/boson_capture.py @@ -149,7 +149,7 @@ def start_recording(self, raw=None, norm=None): print('Recording in Progress!') return - self.raw_data_fpath = raw or 'output.npy' + self.raw_data_fpath = raw or 'output.raw' self.viewable_video_fpath = norm or 'output.mp4' self.recording = True diff --git a/heatseek/cli.py b/heatseek/cli.py index f31a2a3..b9b860f 100644 --- a/heatseek/cli.py +++ b/heatseek/cli.py @@ -5,7 +5,7 @@ import requests from .data_utils import download_dataset, write_data_yaml from .train import train -from .preprocess import reduce_background +from .preprocess import reduce_background, reduce_background_radiometric from .detect_track import detect_and_track from .density_annotator import annotate_folder def main(): @@ -58,6 +58,16 @@ def main(): help="YAML config for optical‐flow thresholds", ) + # preprocess + pre_r = subs.add_parser("preprocess_radiometric", help="Reduce background in a radiometric video") + pre_r.add_argument("--input", required=True, help="Input video path") + pre_r.add_argument("--output", required=True, help="Output video path") + pre_r.add_argument( + "--config", + default="heatseek/config/preproc_config.yaml", + help="YAML config for optical‐flow thresholds", + ) + # track track = subs.add_parser("track", help="Detect + track objects in a video") track.add_argument("--input", required=True, help="Input video path") @@ -112,6 +122,9 @@ def main(): elif args.cmd == "preprocess": reduce_background(args.input, args.output, args.config) + elif args.cmd == "preprocess_radiometric": + reduce_background_radiometric(args.input, args.output, args.config) + elif args.cmd == "track": detect_and_track(args.input, args.output, args.weights) diff --git a/heatseek/preprocess.py b/heatseek/preprocess.py index 1a7cdbb..8208ac1 100644 --- a/heatseek/preprocess.py +++ b/heatseek/preprocess.py @@ -83,4 +83,72 @@ def reduce_background(in_path: str, out_path: str, yaml_path: str = "heatseek/co cap.release() out.release() - print(f"[video_preproc] saved → {out_path}") \ No newline at end of file + print(f"[video_preproc] saved → {out_path}") + + +def reduce_background_radiometric(in_path: str, out_path: str, yaml_path: str = "heatseek/config/preproc_config.yaml"): + """ + Optical flow background reduction using parameters from a YAML config on radiometric data. + + Args: + in_path (str): Path to input video file. + out_path (str): Path to save processed video. + yaml_path (str): Path to YAML configuration file containing motion_thresh and flow_params. + """ + with open(yaml_path, 'r') as f: + config = yaml.safe_load(f) + + motion_thresh = config.get('motion_thresh', 1.0) + flow_cfg = config.get('flow_params', {}) + pyr_scale = flow_cfg.get('pyr_scale', 0.5) + levels = flow_cfg.get('levels', 3) + winsize = flow_cfg.get('winsize', 7) + iterations = flow_cfg.get('iterations', 3) + poly_n = flow_cfg.get('poly_n', 5) + poly_sigma = flow_cfg.get('poly_sigma', 1.2) + flags = flow_cfg.get('flags', 0) + + fps = 30 + width = 320 + height = 256 + fourcc = cv2.VideoWriter_fourcc(*"mp4v") + out = cv2.VideoWriter(out_path, fourcc, fps, (width, height)) + + + mm = np.memmap(in_path, + dtype=np.uint16, + mode='r', + shape=(300, 256, 320)) # TODO remove hardcoding + global_min = np.min(mm) + global_max = np.max(mm) + prev_frame = mm[0] + prev_frame = ((prev_frame - global_min)/(global_max - global_min)*255).astype(np.uint8) + + for i in tqdm(range(299), desc="bg-reduce"): + + frame = mm[i+1] + frame = ((frame - global_min)/(global_max - global_min)*255).astype(np.uint8) + + frame_color = cv2.applyColorMap(frame, cv2.COLORMAP_INFERNO) + + flow = cv2.calcOpticalFlowFarneback( + prev_frame, frame, None, + pyr_scale, levels, winsize, + iterations, poly_n, poly_sigma, flags + ) + + mag, _ = cv2.cartToPolar(flow[..., 0], flow[..., 1]) + + mask = (mag > motion_thresh).astype(np.uint8) * 255 + dist = cv2.distanceTransform(mask, cv2.DIST_L2, 5) + mask = (dist > 1.5).astype(np.uint8) * 255 + mask_color = cv2.merge([mask, mask, mask]) + + fg = cv2.bitwise_and(frame_color, mask_color) + out.write(fg) + + prev_frame = frame + + out.release() + + print(f"[video_preproc] saved → {out_path}") From d41b83fd534f6e466d01374fd4cb40c61f27995f Mon Sep 17 00:00:00 2001 From: Sumega Mandadi Date: Mon, 12 Jan 2026 09:30:16 -1000 Subject: [PATCH 2/8] Add radiometric metadata output file to record script --- heatseek/boson_capture.py | 37 +++++++++++++++++++++++++++++++------ heatseek/preprocess.py | 1 - 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/heatseek/boson_capture.py b/heatseek/boson_capture.py index 4c7b4e3..a0582a2 100644 --- a/heatseek/boson_capture.py +++ b/heatseek/boson_capture.py @@ -10,6 +10,7 @@ import threading import sys import os +import yaml from time import sleep from importlib import import_module import numpy as np @@ -60,6 +61,7 @@ def __init__(self, serial_port=None, video_port=None, sdkpath=None): self.recording = False self.height = 256 self.width = 320 + self.n_frames = 0 self.raw_data_fpath = None self.viewable_video_fpath = None self.recording_thread = None @@ -149,7 +151,7 @@ def start_recording(self, raw=None, norm=None): print('Recording in Progress!') return - self.raw_data_fpath = raw or 'output.raw' + self.raw_data_fpath = raw or 'output.raw' #TODO: add time stamps to defaults self.viewable_video_fpath = norm or 'output.mp4' self.recording = True @@ -185,7 +187,8 @@ def _record_loop(self): dtype=np.uint16, mode='w+', shape=(50000, self.height, self.width)) - frame_index = 0 + #frame_index = 0 + self.n_frames = 0 try: while self.recording: @@ -202,11 +205,13 @@ def _record_loop(self): writer.write(frame_color) # Raw Video - if frame_index > self.raw_mm.shape[0]: + #if frame_index > self.raw_mm.shape[0]: + if self.n_frames > self.raw_mm.shape[0]: print('Reached Preallocated Size, Stopping Recording') break - self.raw_mm[frame_index] = frame - frame_index += 1 + self.raw_mm[self.n_frames] = frame + #frame_index += 1 + self.n_frames += 1 self.raw_mm.flush() finally: @@ -244,10 +249,30 @@ def _finalize_recording(self): del self.raw_mm self.raw_mm = None + self._write_radiometric_metadata() + print('Recording Successfully Completed') - print(f'Radiometric data saved: {self.raw_data_fpath}') + print(f'Radiometric Video: {self.raw_data_fpath}') + print(f'Radiometric Metadata: radiometric_metadata.yaml') # TODO: remove hardcoding of meta data path print(f'Viewable Video: {self.viewable_video_fpath}') + def _write_radiometric_metadata(self): + """Saves out all radiometric video metadata + """ + + meta = { + 'width': self.width, + 'height': self.height, + 'n_frames': self.n_frames, + 'dtype': 'uint16', + } + + out_yaml_path = 'radiometric_metadata.yaml' + + with open(out_yaml_path, "w") as f: + yaml.safe_dump(meta, f, sort_keys=False) + + def release_camera(self): """Release camera resources. diff --git a/heatseek/preprocess.py b/heatseek/preprocess.py index 8208ac1..ae6bc27 100644 --- a/heatseek/preprocess.py +++ b/heatseek/preprocess.py @@ -113,7 +113,6 @@ def reduce_background_radiometric(in_path: str, out_path: str, yaml_path: str = height = 256 fourcc = cv2.VideoWriter_fourcc(*"mp4v") out = cv2.VideoWriter(out_path, fourcc, fps, (width, height)) - mm = np.memmap(in_path, dtype=np.uint16, From 77cb36d10da9b2c4c99142857badcb7ff5710ced Mon Sep 17 00:00:00 2001 From: Sumega Mandadi Date: Mon, 12 Jan 2026 12:03:26 -1000 Subject: [PATCH 3/8] Add timestamps to default output filenames --- examples/boson_record.py | 9 ++++++++- heatseek/boson_capture.py | 17 ++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/examples/boson_record.py b/examples/boson_record.py index 9e1f4b5..aabaa5a 100644 --- a/examples/boson_record.py +++ b/examples/boson_record.py @@ -11,6 +11,7 @@ --video_port path/to/video/port \ --raw_output_path path/to/output.npy \ --video_output_path path/to/output.mp4 \ + --metadata_path path/to/metadata.yaml \ --bosonsdk_path path/to/BosonSDK/SDK_USER_PERMISSIONS \ --recording_time 60 @@ -38,6 +39,8 @@ def main(): radiometric output (.npy) --video_output_path (str, opt): Filepath to save normalized video (.mp4) + --metadata_path (str, opt): Filepath to save + radiometric metadata --bosonsdk_path (str, opt) Filepath to boson sdk folder --recording_time (int, opt): Duration of recording in seconds. Default is 10s. @@ -59,6 +62,9 @@ def main(): parser.add_argument('--video_output_path', type=str, help='filepath to save normalized video output') + parser.add_argument('--metadata_path', + type=str, + help='filepath to save radiometric metadata') parser.add_argument('--bosonsdk_path', type=str, help='filepath to boson sdk') @@ -71,7 +77,8 @@ def main(): video_port=args.video_port, sdkpath=args.bosonsdk_path) camera.start_recording(raw=args.raw_output_path, - norm=args.video_output_path) + norm=args.video_output_path, + meta=args.metadata_path) sleep(args.recording_time or 10) camera.stop_recording() camera.release_camera() diff --git a/heatseek/boson_capture.py b/heatseek/boson_capture.py index a0582a2..3250cc4 100644 --- a/heatseek/boson_capture.py +++ b/heatseek/boson_capture.py @@ -12,6 +12,7 @@ import os import yaml from time import sleep +from datetime import datetime from importlib import import_module import numpy as np import cv2 @@ -64,6 +65,7 @@ def __init__(self, serial_port=None, video_port=None, sdkpath=None): self.n_frames = 0 self.raw_data_fpath = None self.viewable_video_fpath = None + self.metadata_fpath = None self.recording_thread = None self.raw_mm = None @@ -137,7 +139,7 @@ def take_image(self): print('Taking Image- PLACEHOLDER') - def start_recording(self, raw=None, norm=None): + def start_recording(self, raw=None, norm=None, meta=None): """Begin thread for continuous recording Args: @@ -151,8 +153,11 @@ def start_recording(self, raw=None, norm=None): print('Recording in Progress!') return - self.raw_data_fpath = raw or 'output.raw' #TODO: add time stamps to defaults - self.viewable_video_fpath = norm or 'output.mp4' + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + self.raw_data_fpath = raw or f'output_{timestamp}.raw' + self.viewable_video_fpath = norm or f'output_{timestamp}.mp4' + self.metadata_fpath = meta or f'metadata_{timestamp}.yaml' self.recording = True self.recording_thread = threading.Thread( @@ -253,7 +258,7 @@ def _finalize_recording(self): print('Recording Successfully Completed') print(f'Radiometric Video: {self.raw_data_fpath}') - print(f'Radiometric Metadata: radiometric_metadata.yaml') # TODO: remove hardcoding of meta data path + print(f'Radiometric Metadata: {self.metadata_fpath}') print(f'Viewable Video: {self.viewable_video_fpath}') def _write_radiometric_metadata(self): @@ -267,9 +272,7 @@ def _write_radiometric_metadata(self): 'dtype': 'uint16', } - out_yaml_path = 'radiometric_metadata.yaml' - - with open(out_yaml_path, "w") as f: + with open(self.metadata_fpath, "w") as f: yaml.safe_dump(meta, f, sort_keys=False) From b09e887092f84a2c1f897cde3fe5f8c717ce002c Mon Sep 17 00:00:00 2001 From: Sumega Mandadi Date: Mon, 9 Feb 2026 11:05:39 -1000 Subject: [PATCH 4/8] fix globalnorm recording --- examples/boson_record.py | 18 ++-- heatseek/boson_capture.py | 168 ++++++++++++++++++++++++++++++-------- 2 files changed, 143 insertions(+), 43 deletions(-) diff --git a/examples/boson_record.py b/examples/boson_record.py index aabaa5a..d20aad3 100644 --- a/examples/boson_record.py +++ b/examples/boson_record.py @@ -9,8 +9,8 @@ python boson_record.py \ --serial_port path/to/serial/port \ --video_port path/to/video/port \ - --raw_output_path path/to/output.npy \ - --video_output_path path/to/output.mp4 \ + --autonorm_path path/to/output.mp4 \ + --globalnorm_path path/to/output.mp4 \ --metadata_path path/to/metadata.yaml \ --bosonsdk_path path/to/BosonSDK/SDK_USER_PERMISSIONS \ --recording_time 60 @@ -35,9 +35,9 @@ def main(): Default is \dev\ttyACM0. --video_port (str, opt): Path to video port. Default is \dev\video0. - --raw_output_path (str, opt): Filepath to save - radiometric output (.npy) - --video_output_path (str, opt): Filepath to save + --autonorm_path (str, opt): Filepath to save + radiometric output (.mp4) + --globalnorm_path (str, opt): Filepath to save normalized video (.mp4) --metadata_path (str, opt): Filepath to save radiometric metadata @@ -56,10 +56,10 @@ def main(): parser.add_argument('--video_port', type=str, help='path to video port. Default is /dev/video0') - parser.add_argument('--raw_output_path', + parser.add_argument('--autonorm_path', type=str, help='filepath to save raw radiometric output') - parser.add_argument('--video_output_path', + parser.add_argument('--globalnorm_path', type=str, help='filepath to save normalized video output') parser.add_argument('--metadata_path', @@ -76,8 +76,8 @@ def main(): camera = BosonCapture(serial_port=args.serial_port, video_port=args.video_port, sdkpath=args.bosonsdk_path) - camera.start_recording(raw=args.raw_output_path, - norm=args.video_output_path, + camera.start_recording(autonorm=args.autonorm_path, + globalnorm=args.globalnorm_path, meta=args.metadata_path) sleep(args.recording_time or 10) camera.stop_recording() diff --git a/heatseek/boson_capture.py b/heatseek/boson_capture.py index 3250cc4..92d79b0 100644 --- a/heatseek/boson_capture.py +++ b/heatseek/boson_capture.py @@ -18,6 +18,8 @@ import cv2 from heatseek.capture import Capture +import subprocess + class BosonCapture(Capture): """Camera interface for FLIR Boson Radiometric Thermal camera @@ -62,9 +64,11 @@ def __init__(self, serial_port=None, video_port=None, sdkpath=None): self.recording = False self.height = 256 self.width = 320 + self.GLOBAL_MIN = 28000 + self.GLOBAL_MAX = 32000 self.n_frames = 0 - self.raw_data_fpath = None - self.viewable_video_fpath = None + self.autonorm_fpath = None + self.globalnorm_fpath = None self.metadata_fpath = None self.recording_thread = None self.raw_mm = None @@ -139,14 +143,14 @@ def take_image(self): print('Taking Image- PLACEHOLDER') - def start_recording(self, raw=None, norm=None, meta=None): + def start_recording(self, autonorm=None, globalnorm=None, meta=None): """Begin thread for continuous recording Args: - raw (str, opt): filepath to save raw radiometric data. - Defaults to output.npy - norm (str, opt): filepath to save normalized mp4 video. - Defaults to output.mp4 + autonorm (str, opt): filepath to save frame by frame noramlized video. + Defaults to autonorm_{timestamp}.mp4 + globalnorm (str, opt): filepath to save globally normalized mp4 video. + Defaults to globalnorm_{timestamp}.mp4 """ if self.recording: @@ -155,8 +159,8 @@ def start_recording(self, raw=None, norm=None, meta=None): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - self.raw_data_fpath = raw or f'output_{timestamp}.raw' - self.viewable_video_fpath = norm or f'output_{timestamp}.mp4' + self.autonorm_fpath = autonorm or f'autonorm_{timestamp}.mp4' + self.globalnorm_fpath = globalnorm or f'globalnorm_{timestamp}.mkv' self.metadata_fpath = meta or f'metadata_{timestamp}.yaml' self.recording = True @@ -166,6 +170,13 @@ def start_recording(self, raw=None, norm=None, meta=None): self.recording_thread.start() print('Staring Recording...') + def _get_center_temp(self, frame): + + center = frame[int(self.height/2), int(self.width/2)] + #center_k = center/100 + #center_f = (center_k - 273.15) * (9/5) + 32 + return center + def _record_loop(self): """Internal method: recording loop to continuously capture frames @@ -181,46 +192,133 @@ def _record_loop(self): cap.set(cv2.CAP_PROP_CONVERT_RGB, 0) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('Y', '1', '6', ' ')) - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - writer = cv2.VideoWriter(self.viewable_video_fpath, - fourcc, - 60, - (self.width, self.height)) + + mpv4_fourcc = cv2.VideoWriter_fourcc(*'mp4v') + ffv1_fourcc = cv2.VideoWriter_fourcc(*'FFV1') + + autonorm_writer = cv2.VideoWriter(self.autonorm_fpath, + mpv4_fourcc, + 60, + (self.width, self.height)) + globalnorm_writer = cv2.VideoWriter(self.globalnorm_fpath, + ffv1_fourcc, + 60, + (self.width, self.height)) + + ## MKV Setup + proc = subprocess.Popen([ + "ffmpeg", "-y", + "-f", "rawvideo", + "-pixel_format", "gray", + "-video_size", f"{self.width}x{self.height}", + "-framerate", "60", + "-i", "-", + "-c:v", "libx264", + self.globalnorm_fpath + ], stdin=subprocess.PIPE) # raw video setup - self.raw_mm = np.memmap(self.raw_data_fpath, + self.raw_mm = np.memmap('raw_test.raw', dtype=np.uint16, mode='w+', - shape=(50000, self.height, self.width)) - #frame_index = 0 + shape=(600, self.height, self.width)) + frame_index = 0 self.n_frames = 0 + # min max tracker + min_temp = 65535 + max_temp = 0 + try: while self.recording: ret, frame = cap.read() if not ret: print('Frame grab failed. Stopping Recording') break - - # Viewable frame - frame_8bit = cv2.normalize(frame, None, 0, 255, - norm_type=cv2.NORM_MINMAX).astype(np.uint8) - frame_color = cv2.applyColorMap(frame_8bit, - cv2.COLORMAP_INFERNO).astype(np.uint8) - writer.write(frame_color) - + + # Autonorm Frame + #frame_8bit = cv2.normalize(frame, None, 0, 255, + # norm_type=cv2.NORM_MINMAX).astype(np.uint8) + #frame_color = cv2.applyColorMap(frame_8bit, + # cv2.COLORMAP_INFERNO).astype(np.uint8) + #autonorm_writer.write(frame_color) + + # try: + # print(self.raw_mm[self.n_frames-1].dtype, self.raw_mm[self.n_frames-1].min(), self.raw_mm[self.n_frames-1].max()) + # except: + # pass + # Globalnorm Frame + #dn = np.clip(frame, self.GLOBAL_MIN, self.GLOBAL_MAX) + frame_32bit = frame.astype(np.float32) + #frame_32bit = np.clip(frame, self.GLOBAL_MIN, self.GLOBAL_MAX) + + frame_8bit = (255.0 * (frame_32bit-self.GLOBAL_MIN)/(self.GLOBAL_MAX-self.GLOBAL_MIN)).astype(np.uint8) + diff = self.GLOBAL_MAX - self.GLOBAL_MIN + num = (frame - self.GLOBAL_MIN) + num2 = 255*(num.astype(np.float32)) + test_frame = num2/diff + #globalnorm_writer.write(frame_8bit) + + + # to compare mp4 and MKV + frame_3channel = np.stack([frame_8bit, frame_8bit, frame_8bit], axis=-1) + autonorm_writer.write(frame_3channel) + + # MKV DEBUG + proc.stdin.write(frame_8bit.tobytes()) + + # min max tracker + min_temp = min(frame.min(), min_temp) + max_temp = max(frame.max(), max_temp) + + + if frame_index % 60 ==0: + # print(self._get_center_temp(frame)) + # num = frame - self.GLOBAL_MIN + # print(self._get_center_temp(num)) + # div = num/(self.GLOBAL_MAX - self.GLOBAL_MIN) + # print(self._get_center_temp(div)) + # norm = 255*div + # print(self._get_center_temp(norm)) + # norm_int = norm.astype(np.uint8) + # print(self._get_center_temp(norm_int)) + # print(norm_int.shape) + # print(self._get_center_temp(frame_8bit)) + # print('test frame') + print(f'raw: {self._get_center_temp(frame)} | {frame.dtype}') + # print(f'raw - global_min: {self._get_center_temp(num)} | {num.dtype}') + # print(f'global_max - global_min: {diff} | {type(diff)}') + # print(f'255*num: {self._get_center_temp(num2)} | {num2.dtype}') + print(f'Final: {self._get_center_temp(test_frame)} | {test_frame.dtype}') + # #center_norm = (255 * (center-self.GLOBAL_MIN)/(self.GLOBAL_MAX-self.GLOBAL_MIN)).astype(np.uint8) + # #print(f'Center Temp RAW: {self._get_center_temp(frame)} || {center}') + print(f'Center Temp frame_8bit: {self._get_center_temp(frame_8bit)}\n') + # # Raw Video - #if frame_index > self.raw_mm.shape[0]: - if self.n_frames > self.raw_mm.shape[0]: - print('Reached Preallocated Size, Stopping Recording') - break - self.raw_mm[self.n_frames] = frame - #frame_index += 1 - self.n_frames += 1 - self.raw_mm.flush() + if frame_index < self.raw_mm.shape[0]: + if self.n_frames > self.raw_mm.shape[0]: + print('Reached Preallocated Size, Stopping Recording') + break + self.raw_mm[self.n_frames] = frame + frame_index += 1 + self.n_frames += 1 + #print(self.raw_mm[self.n_frames][int(self.height/2), int(self.width/2)]) + self.raw_mm.flush() + finally: + cap.release() + autonorm_writer.release() + globalnorm_writer.release() + self._finalize_recording() + + ## MKV DEBUG + proc.stdin.close() + proc.wait() + + print(f'MIN TEMPERATURE: {min_temp}') + print(f'MAX TEMPERATURE: {max_temp}') def stop_recording(self): """Stop ongoing recording session. @@ -257,7 +355,7 @@ def _finalize_recording(self): self._write_radiometric_metadata() print('Recording Successfully Completed') - print(f'Radiometric Video: {self.raw_data_fpath}') + print(f'Radiometric Video: {self.autonorm_fpath}') print(f'Radiometric Metadata: {self.metadata_fpath}') print(f'Viewable Video: {self.viewable_video_fpath}') @@ -269,6 +367,8 @@ def _write_radiometric_metadata(self): 'width': self.width, 'height': self.height, 'n_frames': self.n_frames, + 'min': self.GLOBAL_MIN, + 'max': self.GLOBAL_MAX, 'dtype': 'uint16', } From 17f5431a3e377fe8a93daa511e4ae919f2d062be Mon Sep 17 00:00:00 2001 From: Sumega Mandadi Date: Mon, 9 Feb 2026 11:37:42 -1000 Subject: [PATCH 5/8] Code Cleanup --- heatseek/boson_capture.py | 138 +++++++++++--------------------------- 1 file changed, 41 insertions(+), 97 deletions(-) diff --git a/heatseek/boson_capture.py b/heatseek/boson_capture.py index 92d79b0..9d0ff8d 100644 --- a/heatseek/boson_capture.py +++ b/heatseek/boson_capture.py @@ -185,25 +185,18 @@ def _record_loop(self): memory is full """ - # video settings + # MP4 Setup cap = cv2.VideoCapture(self.video_port, cv2.CAP_V4L2) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height) cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width) cap.set(cv2.CAP_PROP_CONVERT_RGB, 0) cap.set(cv2.CAP_PROP_FOURCC, - cv2.VideoWriter_fourcc('Y', '1', '6', ' ')) - - mpv4_fourcc = cv2.VideoWriter_fourcc(*'mp4v') - ffv1_fourcc = cv2.VideoWriter_fourcc(*'FFV1') - + cv2.VideoWriter_fourcc('Y', '1', '6', ' ')) + mpv4_fourcc = cv2.VideoWriter_fourcc(*'mp4v') autonorm_writer = cv2.VideoWriter(self.autonorm_fpath, mpv4_fourcc, 60, (self.width, self.height)) - globalnorm_writer = cv2.VideoWriter(self.globalnorm_fpath, - ffv1_fourcc, - 60, - (self.width, self.height)) ## MKV Setup proc = subprocess.Popen([ @@ -218,17 +211,13 @@ def _record_loop(self): ], stdin=subprocess.PIPE) # raw video setup - self.raw_mm = np.memmap('raw_test.raw', - dtype=np.uint16, - mode='w+', - shape=(600, self.height, self.width)) - frame_index = 0 + # self.raw_mm = np.memmap('raw_test.raw', + # dtype=np.uint16, + # mode='w+', + # shape=(600, self.height, self.width)) + # frame_index = 0 self.n_frames = 0 - # min max tracker - min_temp = 65535 - max_temp = 0 - try: while self.recording: ret, frame = cap.read() @@ -236,89 +225,44 @@ def _record_loop(self): print('Frame grab failed. Stopping Recording') break - # Autonorm Frame - #frame_8bit = cv2.normalize(frame, None, 0, 255, - # norm_type=cv2.NORM_MINMAX).astype(np.uint8) - #frame_color = cv2.applyColorMap(frame_8bit, - # cv2.COLORMAP_INFERNO).astype(np.uint8) - #autonorm_writer.write(frame_color) - - # try: - # print(self.raw_mm[self.n_frames-1].dtype, self.raw_mm[self.n_frames-1].min(), self.raw_mm[self.n_frames-1].max()) - # except: - # pass - # Globalnorm Frame - #dn = np.clip(frame, self.GLOBAL_MIN, self.GLOBAL_MAX) - frame_32bit = frame.astype(np.float32) - #frame_32bit = np.clip(frame, self.GLOBAL_MIN, self.GLOBAL_MAX) - - frame_8bit = (255.0 * (frame_32bit-self.GLOBAL_MIN)/(self.GLOBAL_MAX-self.GLOBAL_MIN)).astype(np.uint8) - diff = self.GLOBAL_MAX - self.GLOBAL_MIN - num = (frame - self.GLOBAL_MIN) - num2 = 255*(num.astype(np.float32)) - test_frame = num2/diff - #globalnorm_writer.write(frame_8bit) - - - # to compare mp4 and MKV - frame_3channel = np.stack([frame_8bit, frame_8bit, frame_8bit], axis=-1) - autonorm_writer.write(frame_3channel) - - # MKV DEBUG - proc.stdin.write(frame_8bit.tobytes()) - - # min max tracker - min_temp = min(frame.min(), min_temp) - max_temp = max(frame.max(), max_temp) - - - if frame_index % 60 ==0: + # Autonorm Frame (MP4) + frame_8bit_autonorm = cv2.normalize(frame, None, 0, 255, + norm_type=cv2.NORM_MINMAX).astype(np.uint8) + frame_color = cv2.applyColorMap(frame_8bit_autonorm, + cv2.COLORMAP_INFERNO).astype(np.uint8) + autonorm_writer.write(frame_color) + + # Globalnorm Frame (MKV) + frame_32bit = frame.astype(np.float32) + frame_8bit_globalnorm = (255.0 * (frame_32bit-self.GLOBAL_MIN)/(self.GLOBAL_MAX-self.GLOBAL_MIN)).astype(np.uint8) + proc.stdin.write(frame_8bit_globalnorm.tobytes()) + + + # if frame_index % 60 ==0: # print(self._get_center_temp(frame)) - # num = frame - self.GLOBAL_MIN - # print(self._get_center_temp(num)) - # div = num/(self.GLOBAL_MAX - self.GLOBAL_MIN) - # print(self._get_center_temp(div)) - # norm = 255*div - # print(self._get_center_temp(norm)) - # norm_int = norm.astype(np.uint8) - # print(self._get_center_temp(norm_int)) - # print(norm_int.shape) - # print(self._get_center_temp(frame_8bit)) - # print('test frame') - print(f'raw: {self._get_center_temp(frame)} | {frame.dtype}') - # print(f'raw - global_min: {self._get_center_temp(num)} | {num.dtype}') - # print(f'global_max - global_min: {diff} | {type(diff)}') - # print(f'255*num: {self._get_center_temp(num2)} | {num2.dtype}') - print(f'Final: {self._get_center_temp(test_frame)} | {test_frame.dtype}') - # #center_norm = (255 * (center-self.GLOBAL_MIN)/(self.GLOBAL_MAX-self.GLOBAL_MIN)).astype(np.uint8) - # #print(f'Center Temp RAW: {self._get_center_temp(frame)} || {center}') - print(f'Center Temp frame_8bit: {self._get_center_temp(frame_8bit)}\n') - # + # Raw Video - if frame_index < self.raw_mm.shape[0]: - if self.n_frames > self.raw_mm.shape[0]: - print('Reached Preallocated Size, Stopping Recording') - break - self.raw_mm[self.n_frames] = frame - frame_index += 1 - self.n_frames += 1 - #print(self.raw_mm[self.n_frames][int(self.height/2), int(self.width/2)]) - self.raw_mm.flush() + # if frame_index < self.raw_mm.shape[0]: + # if self.n_frames > self.raw_mm.shape[0]: + # print('Reached Preallocated Size, Stopping Recording') + # break + # self.raw_mm[self.n_frames] = frame + # frame_index += 1 + # self.n_frames += 1 + # self.raw_mm.flush() + + self.n_frames += 1 finally: cap.release() autonorm_writer.release() - globalnorm_writer.release() self._finalize_recording() - ## MKV DEBUG proc.stdin.close() proc.wait() - - print(f'MIN TEMPERATURE: {min_temp}') - print(f'MAX TEMPERATURE: {max_temp}') + def stop_recording(self): """Stop ongoing recording session. @@ -346,18 +290,18 @@ def _finalize_recording(self): """ self.recording = False - - if self.raw_mm is not None: - self.raw_mm.flush() - del self.raw_mm - self.raw_mm = None +# + # if self.raw_mm is not None: + # self.raw_mm.flush() + # del self.raw_mm + # self.raw_mm = None self._write_radiometric_metadata() print('Recording Successfully Completed') - print(f'Radiometric Video: {self.autonorm_fpath}') + print(f'Viewable Video: {self.autonorm_fpath}') + print(f'Globally normalized Video: {self.globalnorm_fpath}') print(f'Radiometric Metadata: {self.metadata_fpath}') - print(f'Viewable Video: {self.viewable_video_fpath}') def _write_radiometric_metadata(self): """Saves out all radiometric video metadata From 5f422095ccdff53fd45500af96bfadac0fb67359 Mon Sep 17 00:00:00 2001 From: Sumega Mandadi Date: Mon, 9 Feb 2026 13:18:20 -1000 Subject: [PATCH 6/8] Fix recording bug --- examples/boson_record.py | 29 +++++++++++++++++++++++++---- heatseek/cli.py | 3 ++- heatseek/config/preproc_config.yaml | 2 +- heatseek/preprocess.py | 17 +++++++++++++---- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/examples/boson_record.py b/examples/boson_record.py index d20aad3..c458811 100644 --- a/examples/boson_record.py +++ b/examples/boson_record.py @@ -20,7 +20,8 @@ """ import argparse -from time import sleep +from time import time, sleep +import keyboard from heatseek.boson_capture import BosonCapture @@ -73,15 +74,35 @@ def main(): help='time in s to record') args = parser.parse_args() + + camera = BosonCapture(serial_port=args.serial_port, video_port=args.video_port, sdkpath=args.bosonsdk_path) camera.start_recording(autonorm=args.autonorm_path, globalnorm=args.globalnorm_path, meta=args.metadata_path) - sleep(args.recording_time or 10) - camera.stop_recording() - camera.release_camera() + + + max_recording_time = args.recording_time or 10800 # 3hrs default + start_time = time() + + try: + print('Recording ... Press CTRL+C to stop manually') + while True: + elapsed = time() - start_time + if elapsed >= max_recording_time: + print('Reached max recording time. Stopping recording') + break + sleep(.01) + + except KeyboardInterrupt: + print('Stopping Recording') + + finally: + # sleep(args.recording_time or 10) + camera.stop_recording() + camera.release_camera() if __name__ == '__main__': diff --git a/heatseek/cli.py b/heatseek/cli.py index b9b860f..05d395f 100644 --- a/heatseek/cli.py +++ b/heatseek/cli.py @@ -62,6 +62,7 @@ def main(): pre_r = subs.add_parser("preprocess_radiometric", help="Reduce background in a radiometric video") pre_r.add_argument("--input", required=True, help="Input video path") pre_r.add_argument("--output", required=True, help="Output video path") + pre_r.add_argument("--metadata", required=True, help="Radiometic video metadata path") pre_r.add_argument( "--config", default="heatseek/config/preproc_config.yaml", @@ -123,7 +124,7 @@ def main(): reduce_background(args.input, args.output, args.config) elif args.cmd == "preprocess_radiometric": - reduce_background_radiometric(args.input, args.output, args.config) + reduce_background_radiometric(args.input, args.output, args.metadata, args.config) elif args.cmd == "track": detect_and_track(args.input, args.output, args.weights) diff --git a/heatseek/config/preproc_config.yaml b/heatseek/config/preproc_config.yaml index ea0127f..d420de8 100644 --- a/heatseek/config/preproc_config.yaml +++ b/heatseek/config/preproc_config.yaml @@ -1,5 +1,5 @@ # preproc_config.yaml -motion_thresh: 2.75 +motion_thresh: 0.5 flow_params: pyr_scale: 0.5 levels: 3 diff --git a/heatseek/preprocess.py b/heatseek/preprocess.py index ae6bc27..d5df1e5 100644 --- a/heatseek/preprocess.py +++ b/heatseek/preprocess.py @@ -86,15 +86,20 @@ def reduce_background(in_path: str, out_path: str, yaml_path: str = "heatseek/co print(f"[video_preproc] saved → {out_path}") -def reduce_background_radiometric(in_path: str, out_path: str, yaml_path: str = "heatseek/config/preproc_config.yaml"): +def reduce_background_radiometric(in_path: str, + out_path: str, + meta_path: str, + yaml_path: str = "heatseek/config/preproc_config.yaml"): """ Optical flow background reduction using parameters from a YAML config on radiometric data. Args: in_path (str): Path to input video file. out_path (str): Path to save processed video. + meta_path (str): Path to radiometric metadata. yaml_path (str): Path to YAML configuration file containing motion_thresh and flow_params. """ + with open(yaml_path, 'r') as f: config = yaml.safe_load(f) @@ -108,16 +113,20 @@ def reduce_background_radiometric(in_path: str, out_path: str, yaml_path: str = poly_sigma = flow_cfg.get('poly_sigma', 1.2) flags = flow_cfg.get('flags', 0) + with open(meta_path, 'r') as f: + meta = yaml.safe_load(f) + fps = 30 - width = 320 - height = 256 + width = meta.get('width', 320) + height = meta.get('height', 256) + n_frames = meta.get('n_frames', 600) fourcc = cv2.VideoWriter_fourcc(*"mp4v") out = cv2.VideoWriter(out_path, fourcc, fps, (width, height)) mm = np.memmap(in_path, dtype=np.uint16, mode='r', - shape=(300, 256, 320)) # TODO remove hardcoding + shape=(n_frames, height, width)) # TODO remove hardcoding global_min = np.min(mm) global_max = np.max(mm) prev_frame = mm[0] From f17d625fbb49c4bfdfefc3b7d192808b7d0399f0 Mon Sep 17 00:00:00 2001 From: Sumega Mandadi Date: Wed, 11 Feb 2026 13:54:02 -1000 Subject: [PATCH 7/8] pylint and flake8 --- examples/boson_record.py | 24 +++++++---------- heatseek/boson_capture.py | 56 +++++++++++++++++---------------------- 2 files changed, 34 insertions(+), 46 deletions(-) diff --git a/examples/boson_record.py b/examples/boson_record.py index c458811..2bbd26e 100644 --- a/examples/boson_record.py +++ b/examples/boson_record.py @@ -21,7 +21,6 @@ import argparse from time import time, sleep -import keyboard from heatseek.boson_capture import BosonCapture @@ -37,14 +36,14 @@ def main(): --video_port (str, opt): Path to video port. Default is \dev\video0. --autonorm_path (str, opt): Filepath to save - radiometric output (.mp4) + normalized viewable video (.mp4) --globalnorm_path (str, opt): Filepath to save - normalized video (.mp4) - --metadata_path (str, opt): Filepath to save + normalized radiometric (.mp4) + --metadata_path (str, opt): Filepath to save radiometric metadata --bosonsdk_path (str, opt) Filepath to boson sdk folder --recording_time (int, opt): Duration of recording in seconds. - Default is 10s. + Default is 3hrs Returns: None @@ -59,10 +58,10 @@ def main(): help='path to video port. Default is /dev/video0') parser.add_argument('--autonorm_path', type=str, - help='filepath to save raw radiometric output') + help='filepath to save noramlized viewable video') parser.add_argument('--globalnorm_path', type=str, - help='filepath to save normalized video output') + help='filepath to save normalized radiometric output') parser.add_argument('--metadata_path', type=str, help='filepath to save radiometric metadata') @@ -74,19 +73,17 @@ def main(): help='time in s to record') args = parser.parse_args() - - + # Camera Setup camera = BosonCapture(serial_port=args.serial_port, video_port=args.video_port, sdkpath=args.bosonsdk_path) camera.start_recording(autonorm=args.autonorm_path, globalnorm=args.globalnorm_path, meta=args.metadata_path) - - - max_recording_time = args.recording_time or 10800 # 3hrs default + max_recording_time = args.recording_time or 10800 # 3hrs default start_time = time() - + + # Recording Loop try: print('Recording ... Press CTRL+C to stop manually') while True: @@ -100,7 +97,6 @@ def main(): print('Stopping Recording') finally: - # sleep(args.recording_time or 10) camera.stop_recording() camera.release_camera() diff --git a/heatseek/boson_capture.py b/heatseek/boson_capture.py index 9d0ff8d..ff143e7 100644 --- a/heatseek/boson_capture.py +++ b/heatseek/boson_capture.py @@ -10,16 +10,15 @@ import threading import sys import os -import yaml from time import sleep -from datetime import datetime +import subprocess from importlib import import_module +from datetime import datetime +import yaml import numpy as np import cv2 from heatseek.capture import Capture -import subprocess - class BosonCapture(Capture): """Camera interface for FLIR Boson Radiometric Thermal camera @@ -50,7 +49,7 @@ def __init__(self, serial_port=None, video_port=None, sdkpath=None): parameters Args: - serial_port (str, opt): path to serial port. + serial_port (str, opt): path to serial port. Defaults to \dev\ttyACM0. video_port (str, opt): path to video port. Defaults to \dev\video0. @@ -147,10 +146,10 @@ def start_recording(self, autonorm=None, globalnorm=None, meta=None): """Begin thread for continuous recording Args: - autonorm (str, opt): filepath to save frame by frame noramlized video. - Defaults to autonorm_{timestamp}.mp4 - globalnorm (str, opt): filepath to save globally normalized mp4 video. - Defaults to globalnorm_{timestamp}.mp4 + autonorm (str, opt): filepath to save frame by frame + noramlized video. Defaults to autonorm_{timestamp}.mp4 + globalnorm (str, opt): filepath to save globally + normalized mp4 video. Defaults to globalnorm_{timestamp}.mp4 """ if self.recording: @@ -158,7 +157,7 @@ def start_recording(self, autonorm=None, globalnorm=None, meta=None): return timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - + self.autonorm_fpath = autonorm or f'autonorm_{timestamp}.mp4' self.globalnorm_fpath = globalnorm or f'globalnorm_{timestamp}.mkv' self.metadata_fpath = meta or f'metadata_{timestamp}.yaml' @@ -171,10 +170,10 @@ def start_recording(self, autonorm=None, globalnorm=None, meta=None): print('Staring Recording...') def _get_center_temp(self, frame): + """Internal method: returns pixel value of frame center + """ center = frame[int(self.height/2), int(self.width/2)] - #center_k = center/100 - #center_f = (center_k - 273.15) * (9/5) + 32 return center def _record_loop(self): @@ -191,14 +190,14 @@ def _record_loop(self): cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width) cap.set(cv2.CAP_PROP_CONVERT_RGB, 0) cap.set(cv2.CAP_PROP_FOURCC, - cv2.VideoWriter_fourcc('Y', '1', '6', ' ')) - mpv4_fourcc = cv2.VideoWriter_fourcc(*'mp4v') + cv2.VideoWriter_fourcc('Y', '1', '6', ' ')) + mpv4_fourcc = cv2.VideoWriter_fourcc(*'mp4v') autonorm_writer = cv2.VideoWriter(self.autonorm_fpath, mpv4_fourcc, 60, (self.width, self.height)) - ## MKV Setup + # MKV Setup proc = subprocess.Popen([ "ffmpeg", "-y", "-f", "rawvideo", @@ -215,7 +214,7 @@ def _record_loop(self): # dtype=np.uint16, # mode='w+', # shape=(600, self.height, self.width)) - # frame_index = 0 + self.n_frames = 0 try: @@ -224,23 +223,19 @@ def _record_loop(self): if not ret: print('Frame grab failed. Stopping Recording') break - + # Autonorm Frame (MP4) frame_8bit_autonorm = cv2.normalize(frame, None, 0, 255, - norm_type=cv2.NORM_MINMAX).astype(np.uint8) + norm_type=cv2.NORM_MINMAX).astype(np.uint8) frame_color = cv2.applyColorMap(frame_8bit_autonorm, - cv2.COLORMAP_INFERNO).astype(np.uint8) + cv2.COLORMAP_INFERNO).astype(np.uint8) autonorm_writer.write(frame_color) # Globalnorm Frame (MKV) - frame_32bit = frame.astype(np.float32) + frame_32bit = frame.astype(np.float32) frame_8bit_globalnorm = (255.0 * (frame_32bit-self.GLOBAL_MIN)/(self.GLOBAL_MAX-self.GLOBAL_MIN)).astype(np.uint8) proc.stdin.write(frame_8bit_globalnorm.tobytes()) - - # if frame_index % 60 ==0: - # print(self._get_center_temp(frame)) - # Raw Video # if frame_index < self.raw_mm.shape[0]: # if self.n_frames > self.raw_mm.shape[0]: @@ -252,18 +247,16 @@ def _record_loop(self): # self.raw_mm.flush() self.n_frames += 1 - finally: cap.release() autonorm_writer.release() self._finalize_recording() - + proc.stdin.close() proc.wait() - def stop_recording(self): """Stop ongoing recording session. @@ -290,14 +283,14 @@ def _finalize_recording(self): """ self.recording = False -# + # if self.raw_mm is not None: # self.raw_mm.flush() # del self.raw_mm # self.raw_mm = None self._write_radiometric_metadata() - + print('Recording Successfully Completed') print(f'Viewable Video: {self.autonorm_fpath}') print(f'Globally normalized Video: {self.globalnorm_fpath}') @@ -314,11 +307,10 @@ def _write_radiometric_metadata(self): 'min': self.GLOBAL_MIN, 'max': self.GLOBAL_MAX, 'dtype': 'uint16', - } + } - with open(self.metadata_fpath, "w") as f: + with open(self.metadata_fpath, "w", encoding="utf-8") as f: yaml.safe_dump(meta, f, sort_keys=False) - def release_camera(self): """Release camera resources. From 6ca2d7081f25b268ae61c1b5ee0b9c122bf965b1 Mon Sep 17 00:00:00 2001 From: Sumega Mandadi Date: Thu, 12 Feb 2026 09:47:21 -1000 Subject: [PATCH 8/8] Fix radiometric preprocessing step --- heatseek/preprocess.py | 90 +++++++++++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/heatseek/preprocess.py b/heatseek/preprocess.py index d5df1e5..03434d0 100644 --- a/heatseek/preprocess.py +++ b/heatseek/preprocess.py @@ -1,3 +1,4 @@ +import subprocess import cv2 import numpy as np from tqdm import tqdm @@ -99,7 +100,8 @@ def reduce_background_radiometric(in_path: str, meta_path (str): Path to radiometric metadata. yaml_path (str): Path to YAML configuration file containing motion_thresh and flow_params. """ - + + # Load Config with open(yaml_path, 'r') as f: config = yaml.safe_load(f) @@ -113,50 +115,74 @@ def reduce_background_radiometric(in_path: str, poly_sigma = flow_cfg.get('poly_sigma', 1.2) flags = flow_cfg.get('flags', 0) - with open(meta_path, 'r') as f: - meta = yaml.safe_load(f) - - fps = 30 - width = meta.get('width', 320) - height = meta.get('height', 256) - n_frames = meta.get('n_frames', 600) - fourcc = cv2.VideoWriter_fourcc(*"mp4v") - out = cv2.VideoWriter(out_path, fourcc, fps, (width, height)) + # Load Metadata + # with open(meta_path, 'r') as f: + # meta = yaml.safe_load(f) + # + # fps = 60 + # width = meta.get('width', 320) + # height = meta.get('height', 256) + # n_frames = meta.get('n_frames', 600) + # fourcc = cv2.VideoWriter_fourcc(*"mp4v") + # out = cv2.VideoWriter(out_path, fourcc, fps, (width, height)) - mm = np.memmap(in_path, - dtype=np.uint16, - mode='r', - shape=(n_frames, height, width)) # TODO remove hardcoding - global_min = np.min(mm) - global_max = np.max(mm) - prev_frame = mm[0] - prev_frame = ((prev_frame - global_min)/(global_max - global_min)*255).astype(np.uint8) - - for i in tqdm(range(299), desc="bg-reduce"): + cap = cv2.VideoCapture(in_path) + if not cap.isOpened(): + raise RuntimeError(f'Failed to open video file: {in_path}') + + fps = cap.get(cv2.CAP_PROP_FPS) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + n_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + # Start FFmpeg process + proc = subprocess.Popen([ + "ffmpeg", "-y", + "-f", "rawvideo", + "-pixel_format", "bgr24", + "-video_size", f"{width}x{height}", + "-framerate", f"{int(fps)}", + "-i", "-", # stdin + "-c:v", "libx264", + "-pix_fmt", "yuv420p", # standard for h264 + out_path + ], stdin=subprocess.PIPE) + + # Read first frame + ret, prev_frame = cap.read() + if not ret: + raise RuntimeError("Failed to read the first frame from the video.") + + prev_gray = prev_frame[:,:,0] + + for _ in tqdm(range(n_frames - 1), desc="bg-reduce"): + ret, frame = cap.read() + if not ret: + break - frame = mm[i+1] - frame = ((frame - global_min)/(global_max - global_min)*255).astype(np.uint8) - - frame_color = cv2.applyColorMap(frame, cv2.COLORMAP_INFERNO) + gray = frame[:,:,0] flow = cv2.calcOpticalFlowFarneback( - prev_frame, frame, None, + prev_gray, gray, None, pyr_scale, levels, winsize, iterations, poly_n, poly_sigma, flags ) mag, _ = cv2.cartToPolar(flow[..., 0], flow[..., 1]) - mask = (mag > motion_thresh).astype(np.uint8) * 255 dist = cv2.distanceTransform(mask, cv2.DIST_L2, 5) mask = (dist > 1.5).astype(np.uint8) * 255 mask_color = cv2.merge([mask, mask, mask]) - fg = cv2.bitwise_and(frame_color, mask_color) - out.write(fg) + fg = cv2.bitwise_and(frame, mask_color) - prev_frame = frame + # Write frame to FFmpeg stdin + proc.stdin.write(fg.tobytes()) - out.release() - - print(f"[video_preproc] saved → {out_path}") + prev_gray = gray + + cap.release() + proc.stdin.close() + proc.wait() + + print(f'video saved - {out_path}')