"""
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:
- 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 --mode full --pipeline pipeline.json --input ./images --output ./out [OPTIONS]
Examples:
# Basic usage
uv run python -m phenotypic --mode full --pipeline pipeline.json --input ./images -o ./results
# Dry-run to preview processing plan
uv run python -m phenotypic --mode full --pipeline pipeline.json --input ./images -o ./results --dry-run
# Sample 5 images per dataset for testing
uv run python -m phenotypic --mode full --pipeline pipeline.json --input ./images -o ./results --sample 5
# Resume interrupted processing
uv run python -m phenotypic --mode full --pipeline pipeline.json --input ./images -o ./results --resume
# Restart processing from beginning (clears previous state)
uv run python -m phenotypic --mode full --pipeline pipeline.json --input ./images -o ./results --restart
# SLURM execution (autonomous)
uv run python -m phenotypic --mode full --pipeline pipeline.json --input ./images \
-o ./results \
--slurm slurm_partition=compute \
--slurm slurm_account=proj \
--slurm mem_gb=16
# SLURM with progress monitoring
uv run python -m phenotypic --mode full --pipeline pipeline.json --input ./images \
-o ./results \
--slurm slurm_partition=compute \
--slurm slurm_account=proj \
--wait
# GridImage with custom dimensions
uv run python -m phenotypic --mode full --pipeline pipeline.json --input ./plates \
-o ./results \
--image-type GridImage --nrows 16 --ncols 24
# 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 --mode measure --pipeline pipeline.json \
--output <previous-output-dir>
# Recompile a previous output directory: re-aggregate the master
# measurements CSV, fill in any overlay PNGs missing under
# results/<ds>/overlays/ by reloading their HDFs (threaded across
# --njobs workers, alpha from --overlay-alpha), rerun analysis
# plugins, rebuild the manifest, and regenerate the HTML dashboard.
# Existing overlays are left untouched. Pipeline JSON is NOT
# required:
uv run python -m phenotypic --mode recompile --output <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 are always written under
`<output>/results/<dataset>/overlays/<stem>.png` for forward runs;
`--mode measure` reruns reuse existing overlays and do not regenerate them.
`--mode recompile` fills in only-missing overlay PNGs from HDFs but
leaves existing ones untouched.
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 --mode full --pipeline pipeline.json --input ./images \\
--output ./results \\
--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 --mode full --pipeline pipeline.json --input ./images \\
--output ./results \\
--slurm slurm_partition=compute \\
--slurm slurm_account=lab_proj \\
--dry-run
"""
import logging
import shutil
import sys
from pathlib import Path
from typing import Any, List, Optional, Sequence, Union, cast
import click
from phenotypic import ImagePipeline
from phenotypic._core._image_parts.detection_modes import available_modes
from phenotypic._cli._cli_directory_scanner import (
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 Dataset, ExecutionConfig
from phenotypic._cli._cli_utils import (
normalize_extension,
parse_slurm_args,
resolve_local_worker_count,
)
from phenotypic._cli._cli_recompile_slurm_scripts import (
TASK_FINALIZE,
build_recompile_tasks,
generate_recompile_slurm_scripts,
)
from phenotypic._cli._cli_slurm_config import get_slurm_array_limit
from phenotypic._cli._cli_slurm_submission import submit_slurm_script_chain
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,
)
from phenotypic.sdk_ import (
DIR_RESULTS,
DIR_OVERLAYS,
JOB_METADATA_JSON,
RECOMPILE_TASK_MANIFEST_JSON,
JobMetadataKey,
dashboard_html_path,
dataset_measurements_dir,
load_image_from_hdf,
clear_machine_state,
measurements_parquet_path,
processing_report_html_path,
progress_dir,
recompile_dir,
resolve_processing_state_path,
)
from phenotypic.sdk_.typing_ import CliMode, ImageTypeName, ProcessOnlyLayer
# Set up logger
logger = logging.getLogger(__name__)
[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 _parse_gpu_batch_size(ctx, param, value):
"""--gpu-batch-size: integer images-per-forward, or the literal ``auto``."""
if value == "auto":
return "auto"
try:
return int(value)
except (TypeError, ValueError):
raise click.BadParameter("--gpu-batch-size must be an integer or 'auto'")
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":
if config.nrows is None and config.ncols is None:
grid_str = "auto (from pipeline preset, default 8 × 12)"
else:
nr = "auto" if config.nrows is None else str(config.nrows)
nc = "auto" if config.ncols is None else str(config.ncols)
grid_str = f"{nr} × {nc}"
table.add_row("Grid Dimensions", grid_str)
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()
def _format_explicit_cli_usage_hint() -> str:
"""Return the canonical explicit-path CLI usage hint."""
return (
"Use explicit path options instead:\n"
" python -m phenotypic --mode full --pipeline pipeline.json "
"--input ./images --output ./results\n"
"Short form:\n"
" python -m phenotypic -m full -p pipeline.json -i ./images -o ./results"
)
def _reject_unexpected_positional_args(extra_args: Sequence[str]) -> None:
"""Reject legacy positional CLI arguments with a migration hint.
Args:
extra_args: Unparsed tokens captured by Click after option parsing.
Raises:
click.UsageError: If any extra positional tokens were supplied.
"""
if not extra_args:
return
preview = " ".join(extra_args)
raise click.UsageError(
"Unexpected positional argument(s): "
f"{preview}\n"
"Positional pipeline and input path arguments are no longer supported.\n"
f"{_format_explicit_cli_usage_hint()}"
)
def _print_process_only_dry_run_plan(
config: ExecutionConfig, datasets: List[Dataset], output_dir: Path
) -> None:
"""Print the resolved plan for a process-mode dry run (no processing).
Shows the mode + layer, per-dataset image counts, a sample of mirrored
output paths, the execution backend, and the ``.phenotypic`` machine-state
location. See spec §5.7.
"""
from phenotypic._cli._cli_process_only import process_only_output_path
from phenotypic.sdk_ import phenotypic_cache_dir
layer = config.process_only_layer
backend = "slurm" if config.is_slurm_mode() else "local"
click.echo("\n" + "=" * 80)
click.echo("DRY-RUN MODE: process (No Jobs Will Be Executed)")
click.echo("=" * 80)
click.echo(f"\n Mode: process ({layer})")
click.echo(f" Pipeline: {config.pipeline_json}")
click.echo(f" Input root: {config.input_path}")
click.echo(f" Output dir: {output_dir}")
click.echo(f" Execution: {backend}")
click.echo(f" Cache dir: {phenotypic_cache_dir(output_dir)}")
total_images = 0
click.echo("\nDatasets:")
sample_paths: List[Path] = []
for dataset in datasets:
count = len(dataset.images)
total_images += count
click.echo(f" {dataset.name}: {count} image(s)")
for img in dataset.images[:2]:
sample_paths.append(
process_only_output_path(
output_dir, img, config.input_path, layer # type: ignore[arg-type]
)
)
click.echo("\nSample mirrored output paths:")
for p in sample_paths[:5]:
click.echo(f" {p}")
click.echo("\nProcessing Summary:")
click.echo(f" Total images to process: {total_images}")
click.echo(f" Total datasets: {len(datasets)}")
click.echo(f" Layer exported per image: {layer}")
click.echo(
" No deliverables/, results/, QC, overlays, or dashboard "
"(apply-only export).\n"
)
@click.command(
context_settings={
"allow_extra_args": True,
"help_option_names": ["-h", "--help"],
}
)
@click.option(
"-p",
"--pipeline",
"pipeline_json",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=None,
help=(
"Pipeline config file created with pipeline.to_json(); legacy .json "
"files are accepted."
),
)
@click.option(
"-i",
"--input",
"input_path",
type=click.Path(exists=True, dir_okay=True, file_okay=True, path_type=Path),
default=None,
help="Input image file or directory to process.",
)
@click.option(
"-o",
"--output",
"output_dir",
type=click.Path(path_type=Path),
required=True,
help="Output directory.",
)
@click.option(
"-m",
"--mode",
type=click.Choice(["full", "measure", "recompile", "process"]),
default="full",
show_default=True,
help=(
"Execution mode: full applies the pipeline and measures images; "
"measure reruns measurement from an existing output root; recompile "
"refreshes aggregate outputs from an existing output root; process "
"exports a single layer selected with --layer."
),
)
@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=None,
help="Number of rows for GridImage. Overrides any pipeline-level "
"preset; when omitted, the pipeline preset is used, falling back to 8.",
)
@click.option(
"--ncols",
type=click.IntRange(min=1),
default=None,
help="Number of columns for GridImage. Overrides any pipeline-level "
"preset; when omitted, the pipeline preset is used, falling back to 12.",
)
@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(
"--njobs",
"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(
"--gpu-batch-size",
"gpu_batch_size",
default="1",
show_default=True,
callback=_parse_gpu_batch_size,
help="Images per GPU forward pass (Stage 2). Integer, or 'auto' (VRAM-probe). "
"Effective only for batchable detectors; the 'auto' probe lands in Spec 2.",
)
@click.option(
"--gpu-workers-per-gpu",
"gpu_workers_per_gpu",
type=int,
default=1,
show_default=True,
help="Model replicas packed per physical GPU (Stage 2) to fill a GPU for "
"small models.",
)
@click.option(
"--gpu-shards",
"gpu_shards",
type=int,
default=1,
show_default=True,
help="Parallel Stage-2 GPU tasks (one whole GPU each; SLURM-only, ignored "
"locally). Set to your concurrent-GPU count.",
)
@click.option(
"--gpu-slurm",
"gpu_slurm_args",
multiple=True,
help="GPU-stage (Stage 2) SBATCH resources, e.g. --gpu-slurm "
"slurm_partition=gpu --gpu-slurm slurm_account=<acct>. Inherits/deltas over "
"--slurm (the CPU profile for Stages 1 & 3); auto-adds slurm_gpus_per_node=1.",
)
@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(
"--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",
"no_dataset_column",
is_flag=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)",
)
@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(
"--no-qc",
"no_qc",
is_flag=True,
help="Skip the QC compute step in finalize. QC otherwise runs "
"whenever the pipeline has a non-empty 'qc' section (writing the "
"qc/ artifact and resetting GUI review progress).",
)
@click.option(
"--layer",
"layer",
type=click.Choice(["rgb", "gray", "detect_mat", "objmap"]),
default=None,
help=(
"Layer exported by --mode process. TIFF for rgb/gray/detect_mat; "
"16-bit raw-label PNG for objmap."
),
)
@click.pass_context
def phenotypic_cli(
ctx: click.Context,
pipeline_json: Optional[Path],
input_path: Optional[Path],
output_dir: Path,
mode: str,
image_type: str,
nrows: Optional[int],
ncols: Optional[int],
bit_depth: Optional[int],
detect_mode: str,
n_jobs: int,
slurm_args: Sequence[str],
gpu_batch_size: Union[int, str],
gpu_workers_per_gpu: int,
gpu_shards: int,
gpu_slurm_args: Sequence[str],
force_local: bool,
wait: bool,
ext: str,
overlay_alpha: float,
no_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,
no_qc: bool,
layer: Optional[str],
):
"""
Execute a PhenoTypic pipeline on images.
--pipeline: Path to pipeline configuration file
--input: Image file or directory to process
--mode process --layer {rgb|gray|detect_mat|objmap}: apply-only export
mode. Runs pipeline.apply() and writes a single image layer per input via
the layer accessor's imsave (rgb integer TIFF at the source bit depth;
gray/detect_mat float TIFF preserving full precision; objmap 16-bit
raw-label PNG; PhenoTypic metadata embedded), mirroring the input tree
under --output. Skips measurement, deliverables, QC, and the dashboard;
machine-state (progress manifest, event log, pipeline copy) lives under
<output>/.phenotypic/. Full local + SLURM + resume reuse. Example::
uv run python -m phenotypic --mode process --pipeline pipe.json \\
--input ./plates --output ./out --layer detect_mat --force-local
"""
try:
_reject_unexpected_positional_args(ctx.args)
include_dataset_column = not no_dataset_column
cli_mode = cast(CliMode, mode) # Click's Choice has already validated this.
measure_only = cli_mode == "measure"
recompile_only = cli_mode == "recompile"
process_only_layer: Optional[ProcessOnlyLayer] = None
if cli_mode == "process":
if layer is None:
raise click.UsageError("--mode process requires --layer.")
process_only_layer = cast(ProcessOnlyLayer, layer)
elif layer is not None:
raise click.UsageError("--layer can only be used with --mode process.")
if measure_only or recompile_only:
if input_path is not None:
raise click.UsageError(
f"--mode {cli_mode} does not accept --input; it discovers "
"inputs from the existing output directory."
)
if dry_run:
raise click.UsageError(
f"--dry-run cannot be combined with --mode {cli_mode}."
)
if recompile_only and pipeline_json is not None:
raise click.UsageError(
"--mode recompile does not accept --pipeline; it reloads the "
"pipeline from the existing output directory."
)
# ---- Early validation for --mode process -----------------------
# Process mode is an apply-only export run (one image layer per input,
# mirrored input tree, no measurement / deliverables / QC / dashboard).
if process_only_layer is not None:
if pipeline_json is None or input_path is None:
raise click.UsageError(
"--mode process requires --pipeline and --input."
)
for val, name in (
(metadata_csv, "--metadata"),
(no_qc, "--no-qc"),
(no_dataset_column, "--no-dataset-column"),
):
if val:
click.echo(
f"Warning: {name} is ignored in --mode process "
"(no measurement/aggregation output).",
err=True,
)
# Parse SLURM args before the recompile branch so --mode recompile can
# explicitly choose between local and SLURM recompile dispatch.
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,
)
if recompile_only:
if not output_dir.exists():
raise click.UsageError(
f"--mode recompile output directory does not exist: {output_dir}."
)
if slurm_args_dict and not force_local:
_handle_recompile_slurm(
output_dir=output_dir,
metadata_csv=metadata_csv,
include_dataset_column=include_dataset_column,
overlay_alpha=overlay_alpha,
checkpoint_interval=checkpoint_interval,
slurm_args=slurm_args_dict,
wait=wait,
no_qc=no_qc,
)
else:
_handle_recompile(
output_dir,
metadata_csv,
include_dataset_column,
overlay_alpha,
n_jobs,
no_qc=no_qc,
)
sys.exit(0)
# ---- Early validation for --mode measure (measure_only) --------
# Measure mode 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>/results/*/hdf/ as its image source.
if measure_only:
# Reject incompatible flags first so the user gets a pointed
# rejection ("--mode measure cannot be combined with --X")
# regardless of whether --output / --pipeline are also wrong.
if resume:
raise click.UsageError(
"--mode measure cannot be combined with --resume; "
"--mode measure is a one-shot re-measurement run that does "
"not touch processing state."
)
if restart:
raise click.UsageError(
"--mode measure cannot be combined with --restart; "
"--mode measure reuses existing HDFs and does not clear state."
)
if retry_failures:
raise click.UsageError(
"--mode measure cannot be combined with --retry-failures; "
"--retry-failures only applies to resume runs, and "
"--mode measure does not touch state."
)
if overwrite:
raise click.UsageError(
"--mode measure cannot be combined with --overwrite; "
"--mode measure reruns measurements on existing HDFs and "
"must not delete output directory contents."
)
if sample is not None:
raise click.UsageError(
"--mode measure cannot be combined with --sample; "
"--mode measure operates on every HDF discovered under "
"<output>/results/*/hdf/."
)
if pipeline_json is None:
raise click.UsageError(
"--mode measure requires --pipeline to be specified."
)
if not Path(output_dir).exists():
raise click.UsageError(
f"--mode measure output directory does not exist: {output_dir}. "
"--mode measure is a rerun over an existing forward-run output; "
"point it at a directory produced by a previous "
"`python -m phenotypic ...` invocation."
)
if not measure_only and (pipeline_json is None or input_path is None):
missing = []
if pipeline_json is None:
missing.append("--pipeline")
if input_path is None:
missing.append("--input")
raise click.UsageError(
f"{' and '.join(missing)} required unless --mode recompile is set.\n"
f"{_format_explicit_cli_usage_hint()}"
)
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)
# 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)
# 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
# Click's Choice() validated image_type against {"Image", "GridImage"};
# narrow to the typed alias for ExecutionConfig's Literal-typed field.
narrowed_image_type: ImageTypeName = "GridImage" if image_type == "GridImage" else "Image"
config = ExecutionConfig(
pipeline_json=pipeline_json,
input_path=effective_input_path,
output_dir=output_dir,
image_type=narrowed_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,
measure_only=measure_only,
process_only_layer=process_only_layer, # type: ignore[arg-type]
gpu_batch_size=gpu_batch_size,
gpu_workers_per_gpu=gpu_workers_per_gpu,
gpu_shards=gpu_shards,
gpu_slurm_args=_parse_slurm_args(gpu_slurm_args),
)
# Handle resume mode BEFORE creating output directory
if config.resume:
# 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 (tolerate a legacy-root run)
state_file = resolve_processing_state_path(output_dir)
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 ALL previous machine-state so the
# orchestration re-runs cleanly (fresh state + event log + progress),
# while preserving any output artifacts (results/, deliverables/, qc/)
# that --restart intentionally keeps — unlike --overwrite, which wipes
# the whole dir. Clearing the event log here prevents the restart from
# appending to, and rebuilding its manifest/failure records from, the
# prior run's events.
if restart:
if output_dir.exists():
if clear_machine_state(output_dir):
click.echo(
f"✓ Cleared previous machine-state (.phenotypic/) 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)"
)
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 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 config...", 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:
if config.process_only_layer:
_print_process_only_dry_run_plan(config, datasets, output_dir)
else:
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:
assert resume_state is not None
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)
# Recorded for diagnostic transparency; no longer drives
# resume compatibility (overlays are always-on).
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,
)
# Process-only runs export image layers mirroring the input tree and
# write no results/ or deliverables/ structure; the worker creates its
# own output dirs and the strategy writes the .phenotypic/ manifest.
if not config.process_only_layer:
output_manager.create_structure(datasets)
# Copy pipeline config for reproducibility (skip in measure mode — the
# forward run already copied it). Process-only writes its copy under
# the hidden cache (.phenotypic/pipeline.json), not deliverables/.
if config.process_only_layer:
try:
from phenotypic.sdk_ import phenotypic_cache_pipeline_json_path
cache_copy = phenotypic_cache_pipeline_json_path(output_dir)
cache_copy.parent.mkdir(parents=True, exist_ok=True)
cache_copy.write_bytes(Path(config.pipeline_json).read_bytes())
click.echo(f" Pipeline: {cache_copy}")
except OSError as e:
logger.warning(f"Failed to copy pipeline config: {e}")
click.echo(f"⚠ Warning: Could not copy pipeline config ({e})", err=True)
elif 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 config: {e}")
click.echo(f"⚠ Warning: Could not copy pipeline config ({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)
# Process-only (apply-only) runs export image layers + a progress
# manifest only — no measurement aggregation, HTML report, README,
# or deliverables. The strategy already wrote the mirrored layers and
# the manifest; print a focused summary and exit.
if config.process_only_layer:
click.echo("\n" + "=" * 60)
click.echo("PROCESS-ONLY COMPLETE")
click.echo("=" * 60)
click.echo(f"Layer exported: {config.process_only_layer}")
click.echo(
f"Completed: {results.total_completed}/{results.total_images}"
)
click.echo(f"Failed: {results.total_failed}")
click.echo(f"Duration: {_format_duration(results.duration)}")
click.echo(f"\nMirrored layer files saved under: {output_dir}")
sys.exit(0 if results.total_failed == 0 else 1)
# Load pipeline once for the finalizer — reused by both the
# per-feature split in aggregate_master_csv and the README generator.
finalizer_pipeline: Optional[ImagePipeline] = None
try:
finalizer_pipeline = ImagePipeline.from_json(config.pipeline_json)
except Exception as e:
logger.warning(f"Failed to load pipeline for finalizer: {e}")
# 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,
pipeline=finalizer_pipeline,
no_qc=no_qc,
)
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 = processing_report_html_path(output_dir)
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
readme_pipeline = (
finalizer_pipeline
if finalizer_pipeline is not None
else ImagePipeline.from_json(config.pipeline_json)
)
readme_gen = READMEGenerator(config, readme_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 _regenerate_missing_overlays(
output_dir: Path,
overlay_alpha: float,
n_jobs: int,
) -> None:
"""Re-render overlay PNGs that are missing under ``results/<ds>/overlays/``.
HDF-driven: walks ``results/<ds>/hdf/*.h5`` (the same discovery
used by measure mode) and, for each HDF whose corresponding
overlay PNG is absent, loads the HDF as the right ``Image`` /
``GridImage`` subclass and writes the overlay using the same
:class:`OutputManager` writer the forward run uses. Existing
overlays are left untouched. Per-image failures are logged and
swallowed so one corrupt HDF doesn't abort the rest.
Parallelized with a thread pool sized by ``n_jobs``. Threading
rather than multiprocessing because the heavy ops (h5py reads,
numpy/skimage label2rgb compositing, PNG zlib encoding) all
release the GIL, and per-image memory is large enough that
fan-out to processes risks exhausting RAM.
Args:
output_dir: Existing output directory.
overlay_alpha: Alpha for overlay compositing, mirrors the
``--overlay-alpha`` flag used by forward runs.
n_jobs: Number of worker threads. ``-1`` means allocated CPUs
under SLURM, otherwise host CPUs. ``1`` runs in-thread.
Mirrors the ``--njobs`` flag used by forward runs.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
from rich.console import Console
from rich.progress import (
BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn,
)
console = Console()
try:
datasets = scan_hdf_outputs(output_dir)
except ValueError:
console.print(
"[yellow]No HDFs found under results/; skipping overlay regeneration"
)
return
output_manager = OutputManager.from_config(
base_dir=output_dir,
ext=".png",
include_dataset_column=False,
overlay_alpha=overlay_alpha,
save_overlays=True,
)
work: list[tuple[str, Path]] = []
for dataset in datasets:
for hdf_path in dataset.images:
overlay_path = output_manager.get_output_path(
dataset.name, "overlays", hdf_path.stem
)
if not overlay_path.exists():
work.append((dataset.name, hdf_path))
if not work:
console.print("[green]All overlays present; nothing to regenerate")
return
for dataset_name in {ds for ds, _ in work}:
(output_dir / DIR_RESULTS / dataset_name / DIR_OVERLAYS).mkdir(
parents=True, exist_ok=True
)
workers = resolve_local_worker_count(n_jobs, len(work))
def _render_one(dataset_name: str, hdf_path: Path) -> None:
image = load_image_from_hdf(hdf_path)
output_manager.save_overlay(image, dataset_name, hdf_path.stem)
console.print(
f"[cyan]Regenerating {len(work)} missing overlay(s) "
f"with {workers} thread(s)..."
)
failures = 0
with Progress(
TextColumn("[progress.description]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TimeElapsedColumn(),
console=console,
) as progress:
task_id = progress.add_task("overlays", total=len(work))
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {
pool.submit(_render_one, ds_name, hdf_path): (ds_name, hdf_path)
for ds_name, hdf_path in work
}
for future in as_completed(futures):
ds_name, hdf_path = futures[future]
try:
future.result()
except Exception:
failures += 1
logger.warning(
"Failed to regenerate overlay for %s/%s",
ds_name,
hdf_path.stem,
exc_info=True,
)
finally:
progress.advance(task_id)
if failures:
console.print(
f"[yellow]Overlay regeneration finished with {failures} failure(s); "
f"see logs for details"
)
else:
console.print("[green]Overlay regeneration complete")
def _handle_recompile_slurm(
*,
output_dir: Path,
metadata_csv: Optional[Path],
include_dataset_column: bool,
overlay_alpha: float,
checkpoint_interval: Optional[int],
slurm_args: dict[str, Any],
wait: bool,
no_qc: bool = False,
) -> None:
"""Submit recompile work as a SLURM array chain.
Args:
output_dir: Existing output directory containing ``results/``.
metadata_csv: Optional metadata CSV for the finalizer task.
include_dataset_column: Whether measurement aggregation should add
``Metadata_Dataset`` when source files lack it.
overlay_alpha: Alpha used when re-rendering missing overlays.
checkpoint_interval: Reused as the measurement shard size when
positive; otherwise defaults to 500 files per shard.
slurm_args: Parsed SLURM key/value arguments.
wait: Whether to wait for the finalizer task status.
no_qc: Forwarded from ``--no-qc`` to skip QC compute on the
finalizer task (stashed on the finalizer task metadata so the
recompile worker reads it).
"""
import json
from datetime import datetime
from rich.console import Console
from phenotypic._cli._cli_utils import load_job_metadata
from phenotypic._cli._dashboard import generate_dashboard
output_dir = Path(output_dir)
prog_dir = progress_dir(output_dir)
prog_dir.mkdir(parents=True, exist_ok=True)
console = Console()
job_meta = load_job_metadata(prog_dir)
dataset_names = _discover_recompile_dataset_names(output_dir, job_meta)
if not dataset_names:
error_exit("No datasets found in output directory", str(output_dir))
shard_size = (
checkpoint_interval
if checkpoint_interval is not None and checkpoint_interval > 0
else 500
)
console.print(
f"[bold]Submitting SLURM recompile[/bold] for {output_dir} "
f"({len(dataset_names)} dataset(s))"
)
tasks = build_recompile_tasks(
output_dir=output_dir,
dataset_names=dataset_names,
include_dataset_column=include_dataset_column,
overlay_alpha=overlay_alpha,
shard_size=shard_size,
)
finalizer_task_index: Optional[int] = None
if tasks and tasks[-1].get("task_type") == TASK_FINALIZE:
finalizer_task_index = len(tasks) - 1
tasks[-1][JobMetadataKey.METADATA_CSV] = str(metadata_csv) if metadata_csv else None
tasks[-1][JobMetadataKey.NO_QC] = no_qc
console.print("[cyan]Querying SLURM array limits...[/cyan]")
array_limit = get_slurm_array_limit()
console.print(f"[green]SLURM array limit: {array_limit}[/green]")
scripts = generate_recompile_slurm_scripts(
tasks=tasks,
output_dir=output_dir,
slurm_args=slurm_args,
array_limit=array_limit,
)
if not tasks or not scripts or finalizer_task_index is None:
console.print(
"[yellow]No recompile SLURM tasks/scripts were generated; "
"running local recompile instead.[/yellow]"
)
_handle_recompile(
output_dir,
metadata_csv,
include_dataset_column,
overlay_alpha,
-1,
no_qc=no_qc,
)
return
submission = submit_slurm_script_chain(
flat_chunk_scripts=scripts,
output_dir=output_dir,
slurm_args=slurm_args,
console=console,
)
flat_scripts = submission.flat_scripts
job_ids = submission.job_ids
recompile_manifest_path = (
recompile_dir(progress_dir(output_dir)) / RECOMPILE_TASK_MANIFEST_JSON
)
job_metadata = {
JobMetadataKey.START_TIME: datetime.now().isoformat(timespec="milliseconds"),
JobMetadataKey.EXECUTION_MODE: "slurm",
JobMetadataKey.DATASETS: _build_recompile_job_metadata_datasets(
output_dir,
dataset_names,
job_meta,
),
JobMetadataKey.CHUNK_SCRIPTS: [str(script) for script in flat_scripts],
JobMetadataKey.CHUNK_JOB_IDS: {"0": str(job_ids[0])},
JobMetadataKey.INCLUDE_DATASET_COLUMN: include_dataset_column,
JobMetadataKey.METADATA_CSV: str(metadata_csv) if metadata_csv else None,
JobMetadataKey.INPUT_PATH: str(output_dir),
"recompile": {
"task_manifest": str(recompile_manifest_path),
"finalizer_task_index": finalizer_task_index,
},
}
metadata_path = prog_dir / JOB_METADATA_JSON
metadata_path.write_text(
json.dumps(job_metadata, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
console.print(f"[green]Job metadata: {metadata_path}[/green]")
generate_dashboard(output_dir, execution_mode="slurm")
console.print(
f"[green]Dashboard: {output_dir / 'dashboard.html'}[/green]"
)
if wait:
console.print(
"\n[cyan]Waiting for recompile finalizer "
f"task {finalizer_task_index}...[/cyan]"
)
_wait_for_recompile_finalizer_status(output_dir, finalizer_task_index)
console.print("[bold green]SLURM recompilation complete[/bold green]")
else:
click.echo("\nRecompile jobs submitted. Monitor progress with:")
click.echo(f" Open: {output_dir / 'dashboard.html'}")
click.echo(" squeue -u $USER --array")
click.echo(
f" tail -f {output_dir / 'logs' / 'slurm' / 'recompile'}/*.log"
)
def _discover_recompile_dataset_names(
output_dir: Path,
job_meta: Optional[dict[str, Any]],
) -> list[str]:
"""Discover datasets using the local recompile precedence."""
from phenotypic.sdk_ import DIR_MEASUREMENTS
results_dir = output_dir / DIR_RESULTS
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 / DIR_MEASUREMENTS).is_dir()
)
if not dataset_names and job_meta:
dataset_names = sorted(
(job_meta.get(JobMetadataKey.DATASETS, {}) or {}).keys()
)
return dataset_names
def _build_recompile_job_metadata_datasets(
output_dir: Path,
dataset_names: list[str],
job_meta: Optional[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
"""Build the normal nested ``job_metadata["datasets"]`` shape."""
previous_datasets = (job_meta or {}).get(JobMetadataKey.DATASETS, {}) or {}
datasets: dict[str, dict[str, Any]] = {}
for dataset_name in dataset_names:
images = _recompile_dataset_image_names(output_dir, dataset_name)
if not images:
previous = previous_datasets.get(dataset_name, {})
if isinstance(previous, dict):
previous_images = previous.get("images", [])
if isinstance(previous_images, list):
images = [str(image) for image in previous_images]
datasets[dataset_name] = {"total": len(images), "images": images}
return datasets
def _recompile_dataset_image_names(output_dir: Path, dataset_name: str) -> list[str]:
"""Infer image names from per-image measurement Parquets or HDFs."""
from phenotypic.sdk_ import DIR_MEASUREMENTS, DIR_HDF
dataset_dir = output_dir / DIR_RESULTS / dataset_name
meas_dir = dataset_dir / DIR_MEASUREMENTS
if meas_dir.is_dir():
parquet_stems = sorted(
path.stem
for path in meas_dir.glob("*.parquet")
if not path.name.startswith("_")
)
if parquet_stems:
return parquet_stems
hdf_dir = dataset_dir / DIR_HDF
if hdf_dir.is_dir():
hdf_stems = sorted(path.stem for path in hdf_dir.glob("*.h5"))
if hdf_stems:
return hdf_stems
return []
def _wait_for_recompile_finalizer_status(
output_dir: Path,
finalizer_task_index: int,
poll_interval: float = 10.0,
timeout: Optional[float] = None,
) -> None:
"""Wait until the recompile finalizer status is completed or failed."""
import json
import time
from phenotypic.sdk_ import task_status_path
status_path = task_status_path(output_dir, finalizer_task_index)
deadline = time.monotonic() + timeout if timeout is not None else None
while True:
if status_path.exists():
try:
status = json.loads(status_path.read_text(encoding="utf-8"))
except Exception:
logger.warning("Failed to read recompile status %s", status_path)
else:
state = status.get("status")
if state == "completed":
return
if state == "failed":
error = status.get("error") or "recompile finalizer failed"
raise RuntimeError(str(error))
if deadline is not None and time.monotonic() >= deadline:
raise TimeoutError(
f"Timed out waiting for recompile finalizer status: {status_path}"
)
time.sleep(poll_interval)
def _handle_recompile(
output_dir: Path,
metadata_csv: Optional[Path],
include_dataset_column: bool,
overlay_alpha: float,
n_jobs: int,
no_qc: bool = False,
) -> 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``,
regenerates any missing overlay PNGs from their HDFs, 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.
overlay_alpha: Alpha used when re-rendering missing overlay
PNGs from HDFs. Forwarded from the ``--overlay-alpha``
CLI flag.
n_jobs: Worker thread count for overlay regeneration.
Forwarded from the ``--njobs`` CLI flag (``-1`` = all
cores, ``1`` = single-threaded).
no_qc: Forwarded from ``--no-qc`` to skip the QC compute step
in finalize.
"""
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 (
regenerate_dashboard_artifacts,
)
console = Console()
prog_dir = progress_dir(output_dir)
job_meta = load_job_metadata(prog_dir)
dataset_names = _discover_recompile_dataset_names(output_dir, job_meta)
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,
no_qc=no_qc,
)
if master_path:
console.print(f"[green]Master measurements: {master_path}")
else:
console.print("[yellow]No measurements found for aggregation")
console.print("[cyan]Checking for missing overlays...")
_regenerate_missing_overlays(output_dir, overlay_alpha, n_jobs)
console.print("[cyan]Running analysis plugins...")
try:
import polars as pl
# Plugins consume the post-applied mirror (which carries the
# external metadata join) so dashboard sidecars match what the
# GUI viewer and per-feature splits see. The master archive is
# intentionally metadata-free.
mirror_path = measurements_parquet_path(output_dir)
merged_df: Optional[pl.DataFrame] = None
if mirror_path.exists():
try:
merged_df = pl.read_parquet(mirror_path)
except Exception:
logger.warning(
"Failed to read measurements mirror for analysis plugins",
exc_info=True,
)
_run_analysis_plugins(output_dir, prog_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...")
prog_dir.mkdir(parents=True, exist_ok=True)
datasets_totals: dict[str, int] = {}
for name in dataset_names:
meas_dir = dataset_measurements_dir(output_dir, name)
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
regenerate_dashboard_artifacts(output_dir, job_meta, datasets_totals)
console.print("[green]Manifest + dashboard regenerated")
console.print(f"[green]Dashboard: {dashboard_html_path(output_dir)}")
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__":
phenotypic_cli()