"""
PhenoTypic CLI (v2.0)
=====================
A command-line interface for executing PhenoTypic ImagePipelines on images or
directories of images with support for local parallel processing and autonomous
SLURM cluster execution.
Features:
- Automatic timestamped output directories
- Recursive directory support (1 level deep)
- Dry-run mode for previewing processing plans
- Sample processing mode for testing pipelines
- Resume capability with state tracking
- Local parallel execution (joblib)
- Autonomous SLURM execution with bash scripts
- HTML failure reports with tracebacks
- Progress monitoring tools
Usage:
python -m phenotypic PIPELINE_JSON INPUT_PATH [OPTIONS]
Examples:
# Basic usage with auto-generated output directory
uv run python -m phenotypic pipeline.json ./images
# Specify output directory
uv run python -m phenotypic pipeline.json ./images -o ./results
# Dry-run to preview processing plan
uv run python -m phenotypic pipeline.json ./images --dry-run
# Sample 5 images per dataset for testing
uv run python -m phenotypic pipeline.json ./images --sample 5
# Resume interrupted processing
uv run python -m phenotypic pipeline.json ./images -o ./results --resume
# Restart processing from beginning (clears previous state)
uv run python -m phenotypic pipeline.json ./images -o ./results --restart
# SLURM execution (autonomous)
uv run python -m phenotypic pipeline.json ./images \
--slurm slurm_partition=compute \
--slurm slurm_account=proj \
--slurm mem_gb=16
# SLURM with progress monitoring
uv run python -m phenotypic pipeline.json ./images \
--slurm slurm_partition=compute \
--slurm slurm_account=proj \
--wait
# GridImage with custom dimensions
uv run python -m phenotypic pipeline.json ./plates \
--image-type GridImage --nrows 16 --ncols 24
# Save PNG overlays alongside HDFs (opt-in; default: OFF)
uv run python -m phenotypic pipeline.json ./images --save-overlays
# Rerun measurements on a previous forward run without re-detecting
# (reads HDFs from <previous-output-dir>/results/*/hdf/, rewrites
# parquet measurements + master CSV, skips detection, does NOT
# regenerate overlays, does NOT touch processing state):
uv run python -m phenotypic pipeline.json --measure \
-o <previous-output-dir>
Outputs:
Forward runs write a single HDF5 per input image under
`<output>/results/<dataset>/hdf/<stem>.h5` (layers + metadata + grid
state, reloadable via `Image.load_hdf5` / `GridImage.load_hdf5`).
Overlay PNGs under `<output>/results/<dataset>/overlays/<stem>.png`
are opt-in and only written when `--save-overlays` is set.
SLURM Execution (Autonomous HPC Cluster Processing):
Use --slurm to submit jobs to an HPC cluster via SLURM. The CLI will:
1. Generate SBATCH scripts for each dataset
2. Create array jobs for parallel image processing
3. Automatically handle dependencies and chunking
4. Support optional job monitoring with --wait
Common Academic HPC SLURM Parameters:
slurm_partition Partition/queue name (e.g., compute, gpu, highmem)
slurm_account Account for billing/fairshare (required on most clusters)
slurm_qos Quality of Service tier (e.g., normal, high)
time Wall time in minutes (auto-converts to HH:MM:SS)
mem_gb Memory per node in GB (convenience param, adds "G" suffix)
slurm_cpus_per_task CPUs per task (useful for joblib parallelism)
slurm_constraint Node features/constraints (e.g., gpu_type, cpu_generation)
slurm_mail_type Email notifications (e.g., END, FAIL, ALL)
slurm_mail_user Email address for notifications
Advanced SLURM Parameters:
slurm_nodes Number of nodes (default: 1)
slurm_mem Memory with custom units (e.g., "32G", "1024M")
slurm_mem_per_cpu Memory per CPU instead of per node
slurm_gpus_per_node GPUs per node for GPU-accelerated operations
Time Parameter Notes:
- Use 'time' or 'slurm_time' with integer minutes
- Automatically converts to HH:MM:SS format (e.g., time=120 → 02:00:00)
- Valid range: 1-10080 minutes (1 minute to 7 days)
Example: Submit with account, partition, memory, and time limits
uv run python -m phenotypic pipeline.json ./images \\
--slurm slurm_partition=compute \\
--slurm slurm_account=lab_proj \\
--slurm mem_gb=32 \\
--slurm time=120 \\
--slurm slurm_mail_type=END \\
--slurm slurm_mail_user=user@university.edu \\
--wait
Example: Dry-run to preview SLURM submission plan
uv run python -m phenotypic pipeline.json ./images \\
--slurm slurm_partition=compute \\
--slurm slurm_account=lab_proj \\
--dry-run
"""
import logging
import shutil
import sys
from pathlib import Path
from typing import Optional, Sequence
import click
# Set up logger
logger = logging.getLogger(__name__)
from phenotypic import ImagePipeline
from phenotypic._core._image_parts.detection_modes import available_modes
from phenotypic._cli._cli_directory_scanner import (
generate_timestamped_output_dir,
organize_by_dataset,
scan_directory_structure,
scan_hdf_outputs,
)
from phenotypic._cli._cli_execution_strategies import create_execution_strategy
from phenotypic._cli._cli_interactive import (
execute_dry_run,
get_sample_datasets,
)
from phenotypic._cli._cli_output_manager import OutputManager
from phenotypic._cli._cli_report_generator import HTMLReportGenerator
from phenotypic._cli._cli_state_management import (
create_initial_state,
get_remaining_images_for_datasets,
load_processing_state,
save_processing_state,
update_state_from_events,
validate_resume_compatibility,
)
from phenotypic._cli._cli_types import ExecutionConfig
from phenotypic._cli._cli_utils import normalize_extension, parse_slurm_args
from phenotypic._cli._cli_validation import (
validate_execution_config,
validate_pipeline,
)
from phenotypic._cli._cli_constants import (
MIN_SLURM_TIME_MINUTES,
MAX_SLURM_TIME_MINUTES,
)
[docs]
def setup_logging(debug: bool = False):
"""Configure logging for CLI."""
level = logging.DEBUG if debug else logging.INFO
handler = logging.StreamHandler()
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.setLevel(level)
root_logger.addHandler(handler)
[docs]
def error_exit(message: str, details: Optional[str] = None, code: int = 1) -> None:
"""Exit with consistent error formatting.
Args:
message: Main error message
details: Optional additional details
code: Exit code (default: 1)
"""
click.echo(f"Error: {message}", err=True)
if details:
click.echo(f"\n{details}", err=True)
sys.exit(code)
def _parse_slurm_args(slurm_args: Sequence[str]) -> dict:
"""Parse space-separated KEY=VALUE pairs into dictionary.
Thin wrapper around :func:`phenotypic._cli._cli_utils.parse_slurm_args`
kept for backward compatibility within this module.
"""
return parse_slurm_args(slurm_args)
def _validate_resume_input_images(state, current_datasets) -> tuple[bool, Optional[str]]:
"""
Validate that input image set matches between resume runs.
Checks:
1. All datasets from previous run are present
2. Image filenames match exactly (not just counts)
Args:
state: Saved processing state
current_datasets: Currently scanned datasets
Returns:
Tuple of (is_valid, error_message)
If valid, error_message is None
"""
# Build mapping of current datasets
current_datasets_map = {ds.name: ds for ds in current_datasets}
# Check all previous datasets still exist
for ds_name in state.datasets.keys():
if ds_name not in current_datasets_map:
return (
False,
f"Dataset '{ds_name}' from previous run not found in input directory",
)
# For each dataset, compare actual image names
for ds_name, ds_state in state.datasets.items():
# Get previous image names from state
# Prefer initial_images (set at run start) for accurate validation,
# fallback to completed|failed for backward compatibility with older state files
if ds_state.initial_images:
prev_images = ds_state.initial_images
else:
prev_images = ds_state.completed | ds_state.failed
# Get current image names from scan
current_dataset = current_datasets_map[ds_name]
curr_images = {img.name for img in current_dataset.images}
# Check if sets match exactly (only if we have a valid previous set)
if prev_images and prev_images != curr_images:
missing = prev_images - curr_images
added = curr_images - prev_images
error_parts = [f"Image set mismatch in dataset '{ds_name}':"]
if missing:
error_parts.append(
f" - Missing {len(missing)} images (e.g., {list(missing)[:3]})"
)
if added:
error_parts.append(
f" - Added {len(added)} new images (e.g., {list(added)[:3]})"
)
return False, "\n".join(error_parts)
return True, None
def _format_slurm_time(minutes: int) -> str:
"""
Convert integer minutes to HH:MM:SS SLURM time format.
Args:
minutes: Time in minutes
Returns:
Formatted time string in HH:MM:SS format (or "X days" for multi-day times)
Examples:
>>> _format_slurm_time(90)
'01:30:00'
>>> _format_slurm_time(120)
'02:00:00'
>>> _format_slurm_time(1440)
'1 day'
"""
if minutes >= 1440: # 24 hours or more
days = minutes // 1440
remaining_minutes = minutes % 1440
if remaining_minutes == 0:
return f"{days} day{'s' if days > 1 else ''}"
else:
hours = remaining_minutes // 60
mins = remaining_minutes % 60
return f"{days}d {hours:02d}:{mins:02d}:00"
else:
hours = minutes // 60
mins = minutes % 60
return f"{hours:02d}:{mins:02d}:00"
def _format_slurm_key(key: str) -> str:
"""
Convert SLURM parameter key to user-friendly display name.
Args:
key: SLURM parameter key (e.g., 'slurm_partition', 'mem_gb')
Returns:
Human-readable display name (e.g., 'Partition', 'Memory')
"""
# Map of known keys to friendly display names
key_mapping = {
"slurm_partition": "Partition",
"slurm_account": "Account",
"slurm_qos": "QoS",
"mem_gb": "Memory",
"slurm_mem": "Memory",
"slurm_mem_per_cpu": "Memory/CPU",
"time": "Time Limit",
"slurm_time": "Time Limit",
"slurm_cpus_per_task": "CPUs/Task",
"slurm_nodes": "Nodes",
"slurm_gpus_per_node": "GPUs/Node",
"slurm_constraint": "Constraint",
"slurm_mail_type": "Mail Type",
"slurm_mail_user": "Mail User",
}
if key in key_mapping:
return key_mapping[key]
# For unknown keys, convert to title case and remove slurm_ prefix
display = key.replace("slurm_", "").replace("_", " ").title()
return display
def _format_slurm_value(key: str, value) -> str:
"""
Format SLURM parameter value for display.
Args:
key: SLURM parameter key
value: Parameter value
Returns:
Formatted string for display
"""
# Handle time parameters
if key in ("time", "slurm_time") and isinstance(value, int):
return _format_slurm_time(value)
# Handle memory in GB
if key == "mem_gb":
return f"{value} GB"
# Default: convert to string
return str(value)
def _display_execution_config(config: ExecutionConfig, datasets: list) -> None:
"""Display execution configuration in structured rich Table format.
Args:
config: ExecutionConfig containing pipeline and execution settings
datasets: List of Dataset objects being processed
"""
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
console = Console()
# Determine backend
backend = "SLURM Cluster" if config.is_slurm_mode() else "Local (joblib)"
# Create table
table = Table(
title="Execution Configuration", show_header=False, box=None, padding=(0, 2)
)
table.add_column("Setting", style="cyan", no_wrap=True)
table.add_column("Value", style="white")
# Add configuration rows
table.add_row("Backend", f"[bold]{backend}[/bold]")
table.add_row("Pipeline", str(config.pipeline_json))
table.add_row("Input Path", str(config.input_path))
table.add_row("Output Dir", str(config.output_dir))
table.add_row("", "") # Spacer
# Image settings
table.add_row("Image Type", config.image_type)
if config.image_type == "GridImage":
table.add_row("Grid Dimensions", f"{config.nrows} × {config.ncols}")
if config.bit_depth:
table.add_row("Bit Depth", str(config.bit_depth))
if config.detect_mode != "gray":
table.add_row("Detect Mode", config.detect_mode)
table.add_row("", "") # Spacer
# Execution settings
if config.is_slurm_mode():
# Show all SLURM parameters for debugging
table.add_row("[bold]SLURM Settings[/bold]", "")
for key in sorted(config.slurm_args.keys()):
value = config.slurm_args[key]
display_name = _format_slurm_key(key)
display_value = _format_slurm_value(key, value)
table.add_row(f" {display_name}", display_value)
else:
# Show joblib settings
n_jobs_str = "All cores" if config.n_jobs == -1 else str(config.n_jobs)
table.add_row("Parallel Jobs", n_jobs_str)
table.add_row("", "") # Spacer
# Dataset info
total_images = sum(len(d.images) for d in datasets)
table.add_row("Datasets", str(len(datasets)))
table.add_row("Total Images", str(total_images))
# Processing flags
if config.sample or config.resume or config.retry_failures:
table.add_row("", "") # Spacer
if config.sample:
table.add_row("Sample Mode", f"{config.sample} images per dataset")
if config.resume:
resume_str = "Yes"
if config.retry_failures:
resume_str += " (with failures)"
table.add_row("Resume Mode", resume_str)
# Display the table in a panel
console.print()
console.print(Panel(table, border_style="blue", expand=False))
console.print()
@click.command()
@click.argument(
"pipeline_json",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=None,
required=False,
)
@click.argument(
"input_path",
type=click.Path(exists=True, dir_okay=True, file_okay=True, path_type=Path),
default=None,
required=False,
)
@click.option(
"-o",
"--output-dir",
type=click.Path(path_type=Path),
default=None,
help="Output directory (auto-generated if not specified)",
)
@click.option(
"--image-type",
type=click.Choice(["Image", "GridImage"], case_sensitive=False),
default="GridImage",
show_default=True,
help="Type of image object to instantiate",
)
@click.option(
"--nrows",
type=click.IntRange(min=1),
default=8,
show_default=True,
help="Number of rows for GridImage (must be positive)",
)
@click.option(
"--ncols",
type=click.IntRange(min=1),
default=12,
show_default=True,
help="Number of columns for GridImage (must be positive)",
)
@click.option(
"--bit-depth",
type=int,
default=None,
help="Bit depth of input images (8 or 16)",
)
@click.option(
"--detect-mode",
type=click.Choice(list(available_modes())),
default="gray",
show_default=True,
help="Source channel for the detection matrix",
)
@click.option(
"--n-jobs",
type=int,
default=-1,
show_default=True,
help="Number of parallel jobs for local execution (-1 = all cores)",
)
@click.option(
"--slurm",
"slurm_args",
multiple=True,
help="SLURM parameters as KEY=VALUE pairs. Pass multiple parameters with "
"repeated --slurm flags (e.g., --slurm slurm_partition=compute "
"--slurm mem_gb=16 --slurm time=60). Use slurm_ prefix for "
"standard SBATCH directives, or use convenience params like mem_gb and time.",
)
@click.option(
"--force-local",
is_flag=True,
help="Force local execution even if SLURM available",
)
@click.option(
"--wait",
is_flag=True,
help="Wait and monitor SLURM jobs (default: return immediately)",
)
@click.option(
"--ext",
default="tiff",
show_default=True,
help="(deprecated for HDF output; still used for overlay PNG) "
"File extension for legacy per-layer outputs. Forward runs now "
"write a single .h5 per image; only overlay PNG rendering still "
"consults this value.",
)
@click.option(
"--save-overlays",
is_flag=True,
default=False,
help="Save PNG overlay per image (default: off).",
)
@click.option(
"--measure",
"measure_only",
is_flag=True,
default=False,
help="Rerun pipeline.measure() on HDFs under "
"<output-dir>/results/*/hdf/. Skips detection. Does not regenerate "
"overlays.",
)
@click.option(
"--overlay-alpha",
type=float,
default=0.3,
show_default=True,
help="Alpha transparency for label overlay (0.0-1.0)",
)
@click.option(
"--no-dataset-column",
"include_dataset_column",
is_flag=True,
flag_value=False,
default=True,
help="Exclude 'Metadata_Dataset' column from master_measurements.csv (included by default)",
)
@click.option(
"--dry-run",
is_flag=True,
help="Preview processing plan without executing",
)
@click.option(
"--sample",
type=int,
default=None,
help="Process N random images per dataset for testing",
)
@click.option(
"--random-seed",
type=int,
default=None,
help="Random seed for --sample reproducibility",
)
@click.option(
"--resume",
is_flag=True,
help="Resume interrupted processing from checkpoint",
)
@click.option(
"--retry-failures",
is_flag=True,
help="Include failed images when resuming (requires --resume)",
)
@click.option(
"--restart",
is_flag=True,
help="Restart processing from beginning, clearing previous state (requires --output-dir)",
)
@click.option(
"--overwrite",
is_flag=True,
help="Delete existing output directory contents before processing",
)
@click.option(
"--metadata",
"metadata_csv",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=None,
help="CSV file to inner-join onto master_measurements.csv on shared columns",
)
@click.option(
"--checkpoint-interval",
type=int,
default=None,
help="Insert checkpoint tasks every N images in SLURM arrays (default: auto-estimate)",
)
@click.option(
"--skip-validation",
is_flag=True,
help="Skip pipeline validation (for advanced users)",
)
@click.option(
"--recompile",
type=click.Path(exists=True, path_type=Path),
default=None,
help="Recompile master measurements from existing output directory. "
"Skips pipeline execution — re-aggregates parquets, runs analysis, "
"rebuilds manifest, and regenerates dashboard.",
)
def phenotypic_cli(
pipeline_json: Optional[Path],
input_path: Optional[Path],
output_dir: Optional[Path],
image_type: str,
nrows: int,
ncols: int,
bit_depth: Optional[int],
detect_mode: str,
n_jobs: int,
slurm_args: Sequence[str],
force_local: bool,
wait: bool,
ext: str,
save_overlays: bool,
measure_only: bool,
overlay_alpha: float,
include_dataset_column: bool,
dry_run: bool,
sample: Optional[int],
random_seed: Optional[int],
resume: bool,
retry_failures: bool,
restart: bool,
overwrite: bool,
metadata_csv: Optional[Path],
checkpoint_interval: Optional[int],
skip_validation: bool,
recompile: Optional[Path],
):
"""
Execute a PhenoTypic pipeline on images.
PIPELINE_JSON: Path to pipeline configuration file
INPUT_PATH: Image file or directory to process
"""
try:
if recompile is not None:
_handle_recompile(recompile, metadata_csv, include_dataset_column)
sys.exit(0)
# ---- Early validation for --measure (measure_only) -------------
# --measure is a one-shot re-measurement run over HDFs already
# written by a previous forward run. It is incompatible with any
# flag that implies a fresh detection pass or state mutation, and
# it uses <output-dir>/results/*/hdf/ as its image source, so the
# positional INPUT_PATH becomes optional.
if measure_only:
# Reject incompatible flags first so the user gets a pointed
# rejection ("--measure cannot be combined with --X") regardless
# of whether --output-dir / PIPELINE_JSON are also wrong.
if resume:
raise click.UsageError(
"--measure cannot be combined with --resume; "
"--measure is a one-shot re-measurement run that does "
"not touch processing state."
)
if restart:
raise click.UsageError(
"--measure cannot be combined with --restart; "
"--measure reuses existing HDFs and does not clear state."
)
if retry_failures:
raise click.UsageError(
"--measure cannot be combined with --retry-failures; "
"--retry-failures only applies to resume runs, and "
"--measure does not touch state."
)
if overwrite:
raise click.UsageError(
"--measure cannot be combined with --overwrite; "
"--measure reruns measurements on existing HDFs and "
"must not delete output directory contents."
)
if sample is not None:
raise click.UsageError(
"--measure cannot be combined with --sample; "
"--measure operates on every HDF discovered under "
"<output-dir>/results/*/hdf/."
)
if pipeline_json is None:
raise click.UsageError(
"--measure requires PIPELINE_JSON to be specified."
)
if output_dir is None:
raise click.UsageError(
"--measure requires --output-dir to be specified "
"(it reads HDFs from <output-dir>/results/*/hdf/)."
)
if not Path(output_dir).exists():
raise click.UsageError(
f"--measure output directory does not exist: {output_dir}. "
"--measure is a rerun over an existing forward-run output; "
"point it at a directory produced by a previous "
"`python -m phenotypic ...` invocation."
)
if input_path is not None:
click.echo(
f"Warning: INPUT_PATH ({input_path}) is ignored in "
"--measure mode; images are discovered under "
f"{output_dir}/results/*/hdf/.",
err=True,
)
if not measure_only and (pipeline_json is None or input_path is None):
raise click.UsageError(
"PIPELINE_JSON and INPUT_PATH are required unless --recompile is set."
)
resume_state = None
# Validate extension argument
try:
ext = normalize_extension(ext, ".tiff")
except click.BadParameter as e:
click.echo(str(e), err=True)
sys.exit(1)
# Parse SLURM args
slurm_args_dict = {}
if slurm_args:
try:
slurm_args_dict = _parse_slurm_args(slurm_args)
except click.BadParameter as e:
click.echo(str(e), err=True)
sys.exit(1)
# Validate SLURM time parameter if present
if slurm_args_dict:
# Check for deprecated parameters
if "time_min" in slurm_args_dict:
click.echo(
"Warning: 'time_min' is deprecated. Use 'time' instead.", err=True
)
# Auto-migrate
if "time" not in slurm_args_dict:
slurm_args_dict["time"] = slurm_args_dict.pop("time_min")
else:
slurm_args_dict.pop("time_min")
# Validate time parameter type and range
for time_key in ("time", "slurm_time"):
if time_key in slurm_args_dict:
time_val = slurm_args_dict[time_key]
if not isinstance(time_val, int):
click.echo(
f"Error: '{time_key}' must be an integer (minutes), "
f"got {type(time_val).__name__}",
err=True,
)
sys.exit(1)
# Validate reasonable time range
if time_val < MIN_SLURM_TIME_MINUTES:
click.echo(
f"Error: '{time_key}' must be >= {MIN_SLURM_TIME_MINUTES} minute, got {time_val}",
err=True,
)
sys.exit(1)
elif time_val > MAX_SLURM_TIME_MINUTES:
days = MAX_SLURM_TIME_MINUTES / 1440
click.echo(
f"Warning: '{time_key}' is {time_val} minutes "
f"({time_val / 60:.1f} hours). This exceeds typical cluster limits "
f"({MAX_SLURM_TIME_MINUTES} min / {days:.1f} days).",
err=True,
)
# Validate flags
if retry_failures and not resume:
click.echo("Error: --retry-failures requires --resume", err=True)
sys.exit(1)
if restart and resume:
click.echo("Error: --restart and --resume are mutually exclusive", err=True)
sys.exit(1)
if overwrite and resume:
click.echo(
"Error: --overwrite and --resume are mutually exclusive", err=True
)
sys.exit(1)
if restart and output_dir is None:
click.echo(
"Error: --restart requires --output-dir to be specified", err=True
)
click.echo(
"\nRestart mode clears previous processing state and starts fresh. "
"You must specify the output directory to restart.",
err=True,
)
click.echo(
"\nExample:\n"
" python -m phenotypic pipeline.json ./images \\\n"
" --output-dir ./results_2024-01-12_10-30-45 \\\n"
" --restart",
err=True,
)
sys.exit(1)
# Validate metadata CSV early
if metadata_csv is not None:
import pandas as pd
try:
meta_df = pd.read_csv(metadata_csv)
if len(meta_df) == 0:
click.echo(
f"Warning: metadata CSV '{metadata_csv}' has zero rows",
err=True,
)
except Exception as e:
error_exit(f"Cannot read metadata CSV: {e}")
# Create ExecutionConfig. By this point either measure_only's
# early validation or the non-measure check above has guaranteed
# pipeline_json is non-None; input_path may only be None in
# --measure mode, where we substitute output_dir to satisfy the
# dataclass's non-optional ``input_path`` (the measure path
# never consults it for image discovery).
assert pipeline_json is not None # narrowed by earlier UsageError branches
effective_input_path = input_path if input_path is not None else output_dir
assert effective_input_path is not None # narrowed by earlier --output-dir checks
config = ExecutionConfig(
pipeline_json=pipeline_json,
input_path=effective_input_path,
output_dir=output_dir,
image_type=image_type,
nrows=nrows,
ncols=ncols,
bit_depth=bit_depth,
detect_mode=detect_mode,
n_jobs=n_jobs,
slurm_args=slurm_args_dict,
force_local=force_local,
wait=wait,
ext=ext,
overlay_alpha=overlay_alpha,
include_dataset_column=include_dataset_column,
dry_run=dry_run,
sample=sample,
resume=resume,
retry_failures=retry_failures,
skip_validation=skip_validation,
metadata_csv=metadata_csv,
checkpoint_interval=checkpoint_interval,
save_overlays=save_overlays,
measure_only=measure_only,
)
# Handle resume mode BEFORE creating output directory
if config.resume:
# For resume, output_dir must be specified
if output_dir is None:
click.echo(
"Error: --resume requires --output-dir to be specified", err=True
)
click.echo(
"\nResume mode continues processing from a previous run. "
"You must specify the same output directory that was used before.",
err=True,
)
click.echo(
"\nExample:\n"
" python -m phenotypic pipeline.json ./images \\\n"
" --output-dir ./results_2024-01-12_10-30-45 \\\n"
" --resume",
err=True,
)
sys.exit(1)
# Check if output directory exists
if not output_dir.exists():
click.echo(
f"Error: Output directory does not exist: {output_dir}", err=True
)
click.echo(
"\nCannot resume from a directory that doesn't exist. "
"Check the path and try again.",
err=True,
)
sys.exit(1)
# Check for processing state file
state_file = output_dir / "processing_state.json"
if not state_file.exists():
click.echo(f"Error: No processing state found in {output_dir}", err=True)
click.echo(f"\nLooking for: {state_file}", err=True)
click.echo(
"\nThis directory may not contain PhenoTypic processing results, "
"or it was created with an older version that doesn't support resume.",
err=True,
)
# List what's actually in the directory
if output_dir.is_dir():
contents = list(output_dir.iterdir())
if contents:
click.echo(f"\nDirectory contents ({len(contents)} items):")
for item in sorted(contents)[:10]: # Show first 10
click.echo(f" - {item.name}")
if len(contents) > 10:
click.echo(f" ... and {len(contents) - 10} more")
sys.exit(1)
click.echo(f"✓ Resuming from {output_dir}")
# Handle restart mode - clear previous state
if restart:
state_file = output_dir / "processing_state.json"
if output_dir.exists():
if state_file.exists():
state_file.unlink()
click.echo(f"✓ Cleared previous processing state from {output_dir}")
else:
click.echo(
f"Note: No previous state found in {output_dir} (starting fresh)"
)
else:
click.echo(
f"Note: Output directory {output_dir} does not exist yet (starting fresh)"
)
# Generate or validate output directory
if output_dir is None:
output_dir = generate_timestamped_output_dir()
click.echo(f"Auto-generated output directory: {output_dir}")
config.output_dir = output_dir
# Check for existing output directory contents (skip for resume/restart/measure)
if not config.resume and not restart and not measure_only:
if output_dir.exists() and any(output_dir.iterdir()):
if overwrite:
import shutil
click.echo(
f"Overwriting existing output directory: {output_dir}"
)
shutil.rmtree(output_dir)
else:
click.echo(
f"Error: Output directory already contains files: {output_dir}",
err=True,
)
click.echo(
"\nUse --overwrite to delete existing contents and replace them, "
"or choose a different output directory.",
err=True,
)
sys.exit(1)
# Scan directory structure (or discover HDFs if in --measure mode)
if measure_only:
click.echo(f"Discovering HDF outputs under {output_dir}/results/...")
try:
datasets = scan_hdf_outputs(output_dir)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
else:
# Not measure_only → input_path already validated as non-None.
assert input_path is not None
click.echo(f"Scanning {input_path}...")
try:
image_paths_by_dataset = scan_directory_structure(input_path)
datasets = organize_by_dataset(image_paths_by_dataset, output_dir)
except (FileNotFoundError, ValueError) as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
total_images = sum(len(d.images) for d in datasets)
click.echo(f"Found {total_images} images in {len(datasets)} dataset(s)")
# Validate configuration
if not config.skip_validation:
from rich.console import Console
console = Console()
console.print() # Add blank line before validation
# Step 1: Validate execution config
with console.status(
"[bold cyan]Validating execution configuration...", spinner="dots"
):
config_valid, config_error = validate_execution_config(config)
if not config_valid:
console.print(
"[bold red]✗ Execution config validation failed:", style="bold red"
)
console.print(f" - {config_error}", style="red")
sys.exit(1)
console.print("[green]✓ Execution configuration validated")
# Step 2: Validate pipeline loading
with console.status("[bold cyan]Loading pipeline JSON...", spinner="dots"):
pipeline_valid, pipeline_error = validate_pipeline(
config.pipeline_json, config.skip_validation
)
if not pipeline_valid:
console.print("[bold red]✗ Pipeline loading failed:", style="bold red")
console.print(f" - {pipeline_error}", style="red")
sys.exit(1)
console.print("[green]✓ Pipeline loaded successfully")
console.print() # Add blank line after validation
else:
from rich.console import Console
console = Console()
# Display execution configuration
try:
_display_execution_config(config, datasets)
except Exception as e:
click.echo(f"Error displaying configuration: {e}", err=True)
import traceback
traceback.print_exc()
sys.exit(1)
# Handle dry-run mode
if config.dry_run:
execute_dry_run(config, datasets, output_dir)
sys.exit(0)
# Handle sample mode
if config.sample is not None:
click.echo(f"\nSample mode: processing {config.sample} images per dataset")
datasets = get_sample_datasets(
datasets, config.sample, output_dir, random_seed
)
total_images = sum(len(d.images) for d in datasets)
click.echo(f"Processing {total_images} sample images\n")
# Handle resume mode - get remaining images
if config.resume:
# State was already validated earlier, just load it
resume_state = load_processing_state(output_dir)
if resume_state is None:
click.echo(f"Error: No processing state found in {output_dir}", err=True)
sys.exit(1)
# Validate compatibility
is_compatible, error = validate_resume_compatibility(resume_state, config)
if not is_compatible:
click.echo(f"Error: Cannot resume - {error}", err=True)
click.echo(
"\nThe pipeline or configuration has changed since the "
"previous run. Resume is only possible with the same "
"pipeline and compatible settings.",
err=True,
)
sys.exit(1)
# Validate input image set hasn't changed
images_valid, image_error = _validate_resume_input_images(
resume_state, datasets
)
if not images_valid:
click.echo(f"Error: Cannot resume - {image_error}", err=True)
click.echo(
"\nThe input image set has changed since the previous run. "
"Resume is only possible with the same input images.",
err=True,
)
sys.exit(1)
# Get remaining images
datasets = get_remaining_images_for_datasets(
resume_state, datasets, config.retry_failures
)
remaining_images = sum(len(d.images) for d in datasets)
if remaining_images == 0:
click.echo("✓ All images already processed!")
sys.exit(0)
click.echo(f"Resuming processing ({remaining_images} images remaining)")
if config.retry_failures:
click.echo(" - Including previously failed images")
# Ensure output directory exists for processing
output_dir.mkdir(parents=True, exist_ok=True)
# Create initial state (or update if resuming) — skipped in
# --measure mode, which never mutates processing state.
if not measure_only:
if config.resume:
state = update_state_from_events(resume_state, output_dir)
state.execution_mode = "slurm" if config.is_slurm_mode() else "local"
state.pipeline_path = config.pipeline_json
state.input_path = config.input_path
state.output_dir = output_dir
state.config = {
"image_type": config.image_type,
"nrows": config.nrows,
"ncols": config.ncols,
"bit_depth": config.bit_depth,
"detect_mode": config.detect_mode,
"n_jobs": config.n_jobs,
"slurm_args": config.slurm_args,
"ext": config.ext,
"save_overlays": config.save_overlays,
}
else:
state = create_initial_state(config, datasets, output_dir)
# Forward runs: persist save_overlays alongside the other
# resume-compat keys so a later --resume can pick it up.
state.config["save_overlays"] = config.save_overlays
save_processing_state(state, output_dir)
# Create output manager
output_manager = OutputManager.from_config(
base_dir=output_dir,
ext=config.ext,
include_dataset_column=config.include_dataset_column,
overlay_alpha=config.overlay_alpha,
save_overlays=config.save_overlays,
)
output_manager.create_structure(datasets)
# Copy pipeline JSON to output directory for reproducibility
# (skip in --measure mode — the forward run already copied it).
if not measure_only:
try:
copied = _copy_pipeline_to_output(config.pipeline_json, output_dir)
if copied:
click.echo(f" Pipeline: {copied}")
except OSError as e:
logger.warning(f"Failed to copy pipeline JSON: {e}")
click.echo(f"⚠ Warning: Could not copy pipeline JSON ({e})", err=True)
# Create execution strategy
strategy = create_execution_strategy(config, output_manager)
# Execute processing
execution_mode = "SLURM" if config.is_slurm_mode() else "local"
click.echo(f"\nStarting {execution_mode} processing...")
results = strategy.execute(datasets, output_dir)
# Aggregate master CSV (if we have completed results)
if results.total_completed > 0:
click.echo("\nAggregating measurements...")
master_path = output_manager.aggregate_master_csv(
datasets, metadata_csv=config.metadata_csv
)
if master_path:
click.echo(f"✓ Master measurements: {master_path}")
else:
click.echo(
"⚠ Warning: Could not aggregate master CSV (check logs for details)",
err=True,
)
# Write analysis sidecar data for the dashboard
try:
from phenotypic._cli._dashboard._analysis_data import write_analysis_sidecar
write_analysis_sidecar(output_dir, metadata_csv=config.metadata_csv)
except Exception:
logger.warning("Analysis sidecar write failed", exc_info=True)
# Generate HTML report
click.echo("Generating HTML report...")
report_gen = HTMLReportGenerator()
report_path = output_dir / "processing_report.html"
report_gen.generate_report(results, report_path)
click.echo(f"✓ Report: {report_path}")
# Generate README documentation
click.echo("Generating README documentation...")
try:
from phenotypic._cli._cli_readme_generator import READMEGenerator
pipeline = ImagePipeline.from_json(config.pipeline_json)
readme_gen = READMEGenerator(config, pipeline)
readme_path = readme_gen.generate(output_dir, datasets)
click.echo(f"✓ README: {readme_path}")
except Exception as e:
logger.warning(f"Failed to generate README: {e}")
click.echo(f"⚠ Warning: Could not generate README ({e})", err=True)
# Print summary
click.echo("\n" + "=" * 60)
click.echo("PROCESSING COMPLETE")
click.echo("=" * 60)
click.echo(f"Completed: {results.total_completed}/{results.total_images}")
click.echo(f"Failed: {results.total_failed}")
click.echo(f"Success rate: {results.success_rate * 100:.1f}%")
click.echo(f"Duration: {_format_duration(results.duration)}")
click.echo(f"\nResults saved to: {output_dir}")
# Exit with appropriate code
sys.exit(0 if results.total_failed == 0 else 1)
except KeyboardInterrupt:
click.echo("\n\nInterrupted by user", err=True)
sys.exit(130)
except click.UsageError:
# Let Click format UsageError (standard "Usage: ..." + "Error: ..."
# two-line output); do NOT swallow into the "Unexpected error"
# branch below.
raise
except Exception as e:
click.echo(f"\nUnexpected error: {e}", err=True)
import traceback
traceback.print_exc()
sys.exit(1)
def _handle_recompile(
output_dir: Path,
metadata_csv: Optional[Path],
include_dataset_column: bool,
) -> None:
"""Recompile master measurements and dashboard from existing results.
Auto-discovers datasets under ``output_dir/results``, re-aggregates
measurement Parquet files into ``master_measurements.csv``, runs
analysis plugins, rebuilds the progress manifest, and regenerates
the HTML dashboard.
Args:
output_dir: Existing output directory containing ``results/``.
metadata_csv: Optional path to an external metadata CSV for
left-joining onto measurements.
include_dataset_column: Whether to insert ``Metadata_Dataset``
into measurements that lack it.
"""
from rich.console import Console
from phenotypic._cli._cli_chunk_writer import _run_analysis_plugins
from phenotypic._cli._cli_output_manager import aggregate_measurements
from phenotypic._cli._cli_utils import load_job_metadata
from phenotypic._cli._dashboard import (
build_manifest,
generate_dashboard,
)
console = Console()
results_dir = output_dir / "results"
progress_dir = output_dir / "progress"
job_meta = load_job_metadata(progress_dir)
dataset_names: list[str] = []
if results_dir.is_dir():
dataset_names = sorted(
d.name
for d in results_dir.iterdir()
if d.is_dir() and (d / "measurements").is_dir()
)
if not dataset_names and job_meta:
dataset_names = sorted(job_meta.get("datasets", {}).keys())
if not dataset_names:
error_exit("No datasets found in output directory", str(output_dir))
console.print(
f"[bold]Recompiling[/bold] from {output_dir} "
f"({len(dataset_names)} dataset(s))"
)
console.print("[cyan]Aggregating measurements...")
master_path = aggregate_measurements(
output_dir=output_dir,
dataset_names=dataset_names,
include_dataset_column=include_dataset_column,
metadata_csv=metadata_csv,
)
if master_path:
console.print(f"[green]Master measurements: {master_path}")
else:
console.print("[yellow]No measurements found for aggregation")
console.print("[cyan]Running analysis plugins...")
try:
import polars as pl
merged_df: Optional[pl.DataFrame] = None
if master_path and Path(master_path).exists():
try:
merged_df = pl.read_csv(master_path)
except Exception:
logger.warning(
"Failed to read master CSV for analysis plugins",
exc_info=True,
)
_run_analysis_plugins(output_dir, progress_dir, merged_df)
console.print("[green]Analysis plugins complete")
except Exception:
logger.warning("Analysis plugin dispatch failed", exc_info=True)
console.print("[yellow]Analysis plugin dispatch failed (see logs)")
console.print("[cyan]Rebuilding manifest...")
progress_dir.mkdir(parents=True, exist_ok=True)
datasets_totals: dict[str, int] = {}
for name in dataset_names:
meas_dir = results_dir / name / "measurements"
if meas_dir.is_dir():
datasets_totals[name] = len(
[
p for p in meas_dir.glob("*.parquet")
if not p.name.startswith("_")
]
)
else:
datasets_totals[name] = 0
build_manifest(
output_dir=output_dir,
progress_dir=progress_dir,
datasets=datasets_totals,
execution_mode=job_meta.get("execution_mode", "local") if job_meta else "local",
start_time=job_meta.get("start_time", "") if job_meta else "",
slurm_job_ids=job_meta.get("chunk_job_ids") if job_meta else None,
chunk_scripts=job_meta.get("chunk_scripts") if job_meta else None,
input_path=job_meta.get("input_path") if job_meta else None,
)
console.print("[green]Manifest rebuilt")
console.print("[cyan]Regenerating dashboard...")
execution_mode = job_meta.get("execution_mode", "local") if job_meta else "local"
generate_dashboard(output_dir, execution_mode=execution_mode)
console.print(f"[green]Dashboard: {output_dir / 'dashboard.html'}")
console.print(f"\n[bold green]Recompilation complete: {output_dir}")
def _copy_pipeline_to_output(
pipeline_path: Path, output_dir: Path
) -> Optional[Path]:
"""Copy pipeline JSON to output directory if not already present.
Args:
pipeline_path: Path to the source pipeline JSON file.
output_dir: Output directory to copy into.
Returns:
Path to the copy if created, None if skipped.
"""
dest = output_dir / pipeline_path.name
if dest.exists():
return None
if pipeline_path.resolve() == dest.resolve():
return None
shutil.copy2(pipeline_path, dest)
return dest
def _format_duration(seconds: float) -> str:
"""Format duration as human-readable string."""
if seconds < 60:
return f"{seconds:.1f}s"
elif seconds < 3600:
return f"{seconds / 60:.1f} min"
else:
return f"{seconds / 3600:.1f} hr"
if __name__ == "__main__":
main()