Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions mio/cli/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
Command line interface for offline video pre-processing.
"""

from pathlib import Path
from typing import Optional

import click

from mio.models.process import DenoiseConfig
from mio.process.video import denoise_run
from mio.utils.config import update_config_from_json


@click.group()
Expand All @@ -31,12 +35,29 @@ def process() -> None:
type=str,
help="Path to the YAML processing configuration file.",
)
@click.option(
"-u",
"--update-from-json",
type=click.Path(exists=True, path_type=Path),
help="Update config from a processing_log.json file",
)
@click.option(
"--save-yaml",
is_flag=True,
help="Save updated config back to denoise_example.yml",
)
def denoise(
input: str,
denoise_config: str,
update_from_json: Optional[Path] = None,
save_yaml: bool = False,
) -> None:
"""
Denoise a video file.
"""
denoise_config_parsed = DenoiseConfig.from_any(denoise_config)
denoise_run(input, denoise_config_parsed)
config = DenoiseConfig.from_any(denoise_config)

if update_from_json:
config = update_config_from_json(config, update_from_json)

denoise_run(input, config)
108 changes: 105 additions & 3 deletions mio/process/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
This module contains functions for pre-processing video data.
"""

import json
from datetime import datetime
from pathlib import Path
from typing import Optional

Expand Down Expand Up @@ -572,7 +574,6 @@ def batch_export_videos(self) -> None:
logger.info(f"Processing {len(self.frames)} frames with Butterworth filter")
filtered_data = self.apply_filter()
if len(filtered_data) > 0:

np.save(self.output_dir / f"{self.name}_filtered_intensity.npy", filtered_data)

filtered_frames = self.apply_filter_to_frames(filtered_data)
Expand All @@ -583,6 +584,9 @@ def batch_export_videos(self) -> None:
else:
logger.warning("No frames to save after Butterworth filtering")

if self.config.plot:
self.plot_filtered_data(filtered_data)

def plot_filtered_data(self, filtered_data: np.ndarray) -> None:
"""Plot the original and filtered intensity data."""
if not self.config.plot or plt is None:
Expand Down Expand Up @@ -613,13 +617,21 @@ def plot_filtered_data(self, filtered_data: np.ndarray) -> None:
plt.ylabel("Mean Intensity")
plt.grid(True, alpha=0.3)
plt.legend()

plt.savefig(
self.output_dir / f"{self.name}_intensity_plot.png", dpi=300, bbox_inches="tight"
)

if plt.get_backend() != "agg":
plt.show()
plt.savefig("temp_plot.png") # Save temporarily
plot_img = cv2.imread("temp_plot.png")
cv2.imshow("Butterworth Filter Plot", plot_img)
while True:
if cv2.waitKey(1) == 27: # Wait for ESC key
break
cv2.destroyAllWindows()
cv2.waitKey(1) # Extra waitKey to properly close the window
Path("temp_plot.png").unlink() # Remove temporary file

plt.close()


Expand Down Expand Up @@ -767,3 +779,93 @@ def denoise_run(
end_frame=config.interactive_display.end_frame,
)
video_plotter.show()

# Keep just the JSON logging
log_processing_metadata(output_dir, video_path, config, pathstem)


def log_processing_metadata(
output_dir: Path, video_path: str, config: DenoiseConfig, pathstem: str
) -> None:
"""Log processing metadata to a JSON file."""

metadata = {
"timestamp": datetime.now().isoformat(),
"input_video": str(video_path),
"output_directory": str(output_dir),
"processing_config": {
"noise_patch": {
"enabled": config.noise_patch.enable if config.noise_patch else False,
"method": config.noise_patch.method if config.noise_patch else None,
"gradient_config": (
{
"threshold": (
config.noise_patch.gradient_config.threshold
if config.noise_patch
else None
),
}
if config.noise_patch and config.noise_patch.gradient_config
else None
),
"black_area_config": (
{
"consecutive_threshold": (
config.noise_patch.black_area_config.consecutive_threshold
if config.noise_patch
else None
),
"value_threshold": (
config.noise_patch.black_area_config.value_threshold
if config.noise_patch
else None
),
}
if config.noise_patch and config.noise_patch.black_area_config
else None
),
},
"frequency_masking": {
"enabled": config.frequency_masking.enable if config.frequency_masking else False,
"cast_float32": (
config.frequency_masking.cast_float32 if config.frequency_masking else False
),
"cutoff_radius": (
config.frequency_masking.spatial_LPF_cutoff_radius
if config.frequency_masking
else None
),
"vertical_BEF": (
config.frequency_masking.vertical_BEF_cutoff
if config.frequency_masking
else None
),
"horizontal_BEF": (
config.frequency_masking.horizontal_BEF_cutoff
if config.frequency_masking
else None
),
},
"butterworth": {
"enabled": config.butter_filter.enable,
"order": config.butter_filter.order,
"cutoff_frequency": config.butter_filter.cutoff_frequency,
"sampling_rate": config.butter_filter.sampling_rate,
},
},
"output_files": {
"noise_patch": f"output_{pathstem}_patch.avi",
"freq_mask": f"output_{pathstem}_freq_mask.avi",
"butter_filter": f"output_{pathstem}_butter_filter.avi",
"butter_plot": f"{pathstem}_butter_filter_intensity_plot.png",
},
}

# Save as JSON (overwrite any existing file)
log_file = output_dir / "processing_log.json"
logs = {"processing_runs": [metadata]} # Just create new log with single run

with open(log_file, "w") as f: # "w" mode overwrites existing file
json.dump(logs, f, indent=2)
Comment on lines +792 to +869
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should do this for a few reasons, not to belabor the point but we should not need to log what our config was after the fact, we should already know that because we already supplied it when we invoked it! it might be nice to offer, but i would really want to just make a structured model for outputs and have this be a part of that. ad-hoc layouts and formats are not gr8 in general.

but anyway, you'd want this to be like:

Suggested change
metadata = {
"timestamp": datetime.now().isoformat(),
"input_video": str(video_path),
"output_directory": str(output_dir),
"processing_config": {
"noise_patch": {
"enabled": config.noise_patch.enable if config.noise_patch else False,
"method": config.noise_patch.method if config.noise_patch else None,
"gradient_config": (
{
"threshold": (
config.noise_patch.gradient_config.threshold
if config.noise_patch
else None
),
}
if config.noise_patch and config.noise_patch.gradient_config
else None
),
"black_area_config": (
{
"consecutive_threshold": (
config.noise_patch.black_area_config.consecutive_threshold
if config.noise_patch
else None
),
"value_threshold": (
config.noise_patch.black_area_config.value_threshold
if config.noise_patch
else None
),
}
if config.noise_patch and config.noise_patch.black_area_config
else None
),
},
"frequency_masking": {
"enabled": config.frequency_masking.enable if config.frequency_masking else False,
"cast_float32": (
config.frequency_masking.cast_float32 if config.frequency_masking else False
),
"cutoff_radius": (
config.frequency_masking.spatial_LPF_cutoff_radius
if config.frequency_masking
else None
),
"vertical_BEF": (
config.frequency_masking.vertical_BEF_cutoff
if config.frequency_masking
else None
),
"horizontal_BEF": (
config.frequency_masking.horizontal_BEF_cutoff
if config.frequency_masking
else None
),
},
"butterworth": {
"enabled": config.butter_filter.enable,
"order": config.butter_filter.order,
"cutoff_frequency": config.butter_filter.cutoff_frequency,
"sampling_rate": config.butter_filter.sampling_rate,
},
},
"output_files": {
"noise_patch": f"output_{pathstem}_patch.avi",
"freq_mask": f"output_{pathstem}_freq_mask.avi",
"butter_filter": f"output_{pathstem}_butter_filter.avi",
"butter_plot": f"{pathstem}_butter_filter_intensity_plot.png",
},
}
# Save as JSON (overwrite any existing file)
log_file = output_dir / "processing_log.json"
logs = {"processing_runs": [metadata]} # Just create new log with single run
with open(log_file, "w") as f: # "w" mode overwrites existing file
json.dump(logs, f, indent=2)
logs = {
"timestamp": datetime.now().isoformat(),
"input_video": str(video_path),
"output_directory": str(output_dir),
"config": config.model_dump()
}
with open(log_file, "w") as f: # "w" mode overwrites existing file
json.dump(logs, f, indent=2)

no reason to change the keys like this when we're saving the config, might as well just dump the whole thing which has 100% of the information that the config has by definition, and is in a format that can be roundtripped if needed. doing it like this would mean that every time we changed the model, we would need to remember to update this.


logger.info(f"Saved processing metadata to {log_file}")
1 change: 1 addition & 0 deletions mio/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Utility functions for miniscope-io."""
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i have blanket objects to the existence of utils subpackages, as they are, as a rule, junk drawer subpackages that make maintenance unnecessarily hard. for an advanced case of how this plays out, see linkml/linkml#2371

it is true that sometimes things don't have an obvious place, so a single, short utils.py module is fine, but once it starts ballooning...

54 changes: 54 additions & 0 deletions mio/utils/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Configuration utilities for miniscope-io."""

import json
from pathlib import Path

from mio.models.process import DenoiseConfig


def update_config_from_json(config: DenoiseConfig, json_path: Path) -> DenoiseConfig:
"""Update DenoiseConfig from a processing_log.json file."""
with open(json_path) as f:
log_data = json.load(f)
Comment on lines +11 to +12
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally all i/o operations are separated from processing actions (so, e.g., we wouldn't need to write to a temporary tmp.json file and then pass the path to this function if we already had a dict).


# Get most recent processing run
proc_config = log_data["processing_runs"][-1]["processing_config"]

# Update noise patch config
config.noise_patch.enable = proc_config["noise_patch"]["enabled"]
if config.noise_patch.enable:
config.noise_patch.method = proc_config["noise_patch"]["method"]
if proc_config["noise_patch"]["gradient_config"]:
config.noise_patch.gradient_config.threshold = proc_config["noise_patch"][
"gradient_config"
]["threshold"]
if proc_config["noise_patch"]["black_area_config"]:
config.noise_patch.black_area_config.consecutive_threshold = proc_config["noise_patch"][
"black_area_config"
]["consecutive_threshold"]
config.noise_patch.black_area_config.value_threshold = proc_config["noise_patch"][
"black_area_config"
]["value_threshold"]

# Update frequency masking config
config.frequency_masking.enable = proc_config["frequency_masking"]["enabled"]
if config.frequency_masking.enable:
config.frequency_masking.cast_float32 = proc_config["frequency_masking"]["cast_float32"]
config.frequency_masking.spatial_LPF_cutoff_radius = proc_config["frequency_masking"][
"cutoff_radius"
]
config.frequency_masking.vertical_BEF_cutoff = proc_config["frequency_masking"][
"vertical_BEF"
]
config.frequency_masking.horizontal_BEF_cutoff = proc_config["frequency_masking"][
"horizontal_BEF"
]

# Update butterworth config
config.butter_filter.enable = proc_config["butterworth"]["enabled"]
if config.butter_filter.enable:
config.butter_filter.order = proc_config["butterworth"]["order"]
config.butter_filter.cutoff_frequency = proc_config["butterworth"]["cutoff_frequency"]
config.butter_filter.sampling_rate = proc_config["butterworth"]["sampling_rate"]
Comment on lines +17 to +52
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same problem as above, lack of generality, need for maintenance if model changes.

Suggested change
# Update noise patch config
config.noise_patch.enable = proc_config["noise_patch"]["enabled"]
if config.noise_patch.enable:
config.noise_patch.method = proc_config["noise_patch"]["method"]
if proc_config["noise_patch"]["gradient_config"]:
config.noise_patch.gradient_config.threshold = proc_config["noise_patch"][
"gradient_config"
]["threshold"]
if proc_config["noise_patch"]["black_area_config"]:
config.noise_patch.black_area_config.consecutive_threshold = proc_config["noise_patch"][
"black_area_config"
]["consecutive_threshold"]
config.noise_patch.black_area_config.value_threshold = proc_config["noise_patch"][
"black_area_config"
]["value_threshold"]
# Update frequency masking config
config.frequency_masking.enable = proc_config["frequency_masking"]["enabled"]
if config.frequency_masking.enable:
config.frequency_masking.cast_float32 = proc_config["frequency_masking"]["cast_float32"]
config.frequency_masking.spatial_LPF_cutoff_radius = proc_config["frequency_masking"][
"cutoff_radius"
]
config.frequency_masking.vertical_BEF_cutoff = proc_config["frequency_masking"][
"vertical_BEF"
]
config.frequency_masking.horizontal_BEF_cutoff = proc_config["frequency_masking"][
"horizontal_BEF"
]
# Update butterworth config
config.butter_filter.enable = proc_config["butterworth"]["enabled"]
if config.butter_filter.enable:
config.butter_filter.order = proc_config["butterworth"]["order"]
config.butter_filter.cutoff_frequency = proc_config["butterworth"]["cutoff_frequency"]
config.butter_filter.sampling_rate = proc_config["butterworth"]["sampling_rate"]
config = config.model_validate({**config.model_dump(), **proc_config})


return config