Skip to content

Commit aaeb22e

Browse files
committed
add PowerPoint export for orthogonal projections
1 parent c0b2705 commit aaeb22e

10 files changed

Lines changed: 729 additions & 18 deletions

File tree

scripts/figures/orthogonal_projections/cli.py

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@
6767
AnimatedGifOutput,
6868
MovieOutput,
6969
ProjectionOutput,
70+
SyncGifOptions,
71+
build_sync_gif_options,
7072
load_z_stack,
7173
save_all_projections,
7274
save_slice_movies_for_well,
@@ -83,6 +85,7 @@
8385
save_mosaic,
8486
)
8587
from .labeling import FigureLabeler, get_labeler
88+
from .exporters import DeckExportOptions, collect_figure_assets, export_slide_deck
8689
from openhcs.processing.backends.processors.numpy_processor import (
8790
create_orthogonal_projections,
8891
)
@@ -110,6 +113,9 @@ class ProcessingConfig:
110113
create_composites: bool = True
111114
create_movies: bool = False
112115
create_sync_gifs: bool = False
116+
sync_gif_options: SyncGifOptions = SyncGifOptions()
117+
export_pptx: bool = False
118+
pptx_options: Optional[DeckExportOptions] = None
113119
movie_types: Tuple[str, ...] = ("xy", "xz", "yz")
114120
movie_fps: int = 10
115121
movie_bit_depth: int = 8
@@ -147,6 +153,7 @@ class ProcessingResult:
147153
movie_outputs: List[MovieOutput] = field(default_factory=list)
148154
sync_gif_outputs: List[AnimatedGifOutput] = field(default_factory=list)
149155
mosaic_outputs: List[Path] = field(default_factory=list)
156+
deck_outputs: List[Path] = field(default_factory=list)
150157

151158

152159
def _get_default_dtype_config():
@@ -286,7 +293,7 @@ def process_well_all_channels(
286293
layout=CompositeLayout(z_gap=config.z_gap, z_aspect=config.z_aspect),
287294
channel_colors=config.channel_colors,
288295
z_gap=config.z_gap,
289-
fps=config.movie_fps,
296+
options=config.sync_gif_options,
290297
)
291298
for sg in sync_gif_outputs:
292299
logger.info(f" Saved sync GIF: {sg.output_path}")
@@ -361,10 +368,20 @@ def _process_well_worker(
361368
create_sync_gifs = config_dict.get("create_sync_gifs", False)
362369
movie_types = tuple(config_dict.get("movie_types", ["xy", "xz", "yz"]))
363370
movie_fps = config_dict.get("movie_fps", 10)
371+
sync_gif_options_dict = config_dict.get("sync_gif_options", {})
364372
movie_bit_depth = config_dict.get("movie_bit_depth", 8)
365373
z_gap = config_dict.get("z_gap", 1.0)
366374
z_aspect = config_dict.get("z_aspect", 0.1)
367375
dpi = config_dict.get("dpi", 150)
376+
sync_gif_options = build_sync_gif_options(
377+
fps=sync_gif_options_dict.get("fps", movie_fps),
378+
profile=sync_gif_options_dict.get("profile", "quality"),
379+
scale=sync_gif_options_dict.get("scale"),
380+
frame_step=sync_gif_options_dict.get("frame_step"),
381+
max_colors=sync_gif_options_dict.get("max_colors"),
382+
dither=sync_gif_options_dict.get("dither"),
383+
diff_mode=sync_gif_options_dict.get("diff_mode"),
384+
)
368385

369386
z_paths_by_channel = {
370387
WellChannelKey(
@@ -454,7 +471,7 @@ def _process_well_worker(
454471
layout=CompositeLayout(z_gap=z_gap, z_aspect=z_aspect),
455472
channel_colors=channel_colors or DEFAULT_CHANNEL_COLORS,
456473
z_gap=z_gap,
457-
fps=movie_fps,
474+
options=sync_gif_options,
458475
)
459476

460477
return WellResult(
@@ -528,6 +545,15 @@ def process_plate(config: ProcessingConfig) -> ProcessingResult:
528545
"create_sync_gifs": config.create_sync_gifs,
529546
"movie_types": config.movie_types,
530547
"movie_fps": config.movie_fps,
548+
"sync_gif_options": {
549+
"fps": config.sync_gif_options.fps,
550+
"profile": "quality",
551+
"scale": config.sync_gif_options.compression.scale,
552+
"frame_step": config.sync_gif_options.compression.frame_step,
553+
"max_colors": config.sync_gif_options.compression.max_colors,
554+
"dither": config.sync_gif_options.compression.dither,
555+
"diff_mode": config.sync_gif_options.compression.diff_mode,
556+
},
531557
"movie_bit_depth": config.movie_bit_depth,
532558
"z_gap": config.z_gap,
533559
"z_aspect": config.z_aspect,
@@ -671,6 +697,25 @@ def parse_mosaic_group(group_str: str) -> ArbitraryMosaicSpec:
671697
return ArbitraryMosaicSpec(name=name, well_ids=wells)
672698

673699

700+
def export_powerpoint_deck(
701+
config: ProcessingConfig, result: ProcessingResult
702+
) -> Optional[Path]:
703+
"""Export generated figures to a PowerPoint deck."""
704+
if not config.export_pptx or config.pptx_options is None:
705+
return None
706+
707+
assets = collect_figure_assets(
708+
composite_outputs=result.composite_outputs,
709+
sync_gif_outputs=result.sync_gif_outputs,
710+
movie_outputs=result.movie_outputs,
711+
)
712+
deck_result = export_slide_deck(assets, config.pptx_options)
713+
result.deck_outputs.append(deck_result.output_path)
714+
logger.info(f"Saved PowerPoint deck: {deck_result.output_path}")
715+
logger.info(f" Slide count: {deck_result.slide_count}")
716+
return deck_result.output_path
717+
718+
674719
def main():
675720
"""Main entry point."""
676721
parser = argparse.ArgumentParser(
@@ -754,6 +799,66 @@ def main():
754799
help="Create synchronized composite GIFs (XY left, XZ/YZ right)",
755800
)
756801

802+
parser.add_argument(
803+
"--sync-gif-profile",
804+
choices=["quality", "balanced", "powerpoint", "compact"],
805+
default="quality",
806+
help="Compression profile for synchronized GIFs",
807+
)
808+
809+
parser.add_argument(
810+
"--sync-gif-scale",
811+
type=float,
812+
help="Optional scale override for synchronized GIF output",
813+
)
814+
815+
parser.add_argument(
816+
"--sync-gif-frame-step",
817+
type=int,
818+
help="Optional frame-step override for synchronized GIF output",
819+
)
820+
821+
parser.add_argument(
822+
"--sync-gif-max-colors",
823+
type=int,
824+
help="Optional palette-size override for synchronized GIF output",
825+
)
826+
827+
parser.add_argument(
828+
"--sync-gif-dither",
829+
choices=["sierra2_4a", "bayer", "none"],
830+
help="Optional dithering mode for synchronized GIF output",
831+
)
832+
833+
parser.add_argument(
834+
"--export-pptx",
835+
action="store_true",
836+
help="Export generated figures to a PowerPoint deck",
837+
)
838+
839+
parser.add_argument(
840+
"--pptx-output",
841+
help="Output path for PowerPoint deck (defaults to output-dir/orthogonal_projections.pptx)",
842+
)
843+
844+
parser.add_argument(
845+
"--pptx-include-composites",
846+
action="store_true",
847+
help="Include composite PNGs in PowerPoint export",
848+
)
849+
850+
parser.add_argument(
851+
"--pptx-include-sync-gifs",
852+
action="store_true",
853+
help="Include synchronized GIFs in PowerPoint export",
854+
)
855+
856+
parser.add_argument(
857+
"--pptx-include-movies",
858+
action="store_true",
859+
help="Include composite movies in PowerPoint export",
860+
)
861+
757862
parser.add_argument(
758863
"--movie-types",
759864
nargs="+",
@@ -821,6 +926,38 @@ def main():
821926
if args.mosaic_groups:
822927
arbitrary_mosaics = tuple(parse_mosaic_group(g) for g in args.mosaic_groups)
823928

929+
sync_gif_options = build_sync_gif_options(
930+
fps=args.movie_fps,
931+
profile=args.sync_gif_profile,
932+
scale=args.sync_gif_scale,
933+
frame_step=args.sync_gif_frame_step,
934+
max_colors=args.sync_gif_max_colors,
935+
dither=args.sync_gif_dither,
936+
)
937+
938+
pptx_include_any = (
939+
args.pptx_include_composites
940+
or args.pptx_include_sync_gifs
941+
or args.pptx_include_movies
942+
)
943+
pptx_include_composites = args.pptx_include_composites or not pptx_include_any
944+
pptx_include_sync_gifs = args.pptx_include_sync_gifs or not pptx_include_any
945+
pptx_output = (
946+
Path(args.pptx_output)
947+
if args.pptx_output
948+
else Path(args.output_dir) / "orthogonal_projections.pptx"
949+
)
950+
pptx_options = None
951+
if args.export_pptx:
952+
pptx_options = DeckExportOptions(
953+
output_path=pptx_output,
954+
include_composites=pptx_include_composites,
955+
include_sync_gifs=pptx_include_sync_gifs,
956+
include_movies=args.pptx_include_movies,
957+
deck_title="OpenHCS Orthogonal Projections",
958+
plate_name=Path(args.plate_dir).name,
959+
)
960+
824961
config = ProcessingConfig(
825962
plate_path=Path(args.plate_dir),
826963
output_dir=Path(args.output_dir),
@@ -835,6 +972,9 @@ def main():
835972
create_composites=not args.no_composites,
836973
create_movies=args.create_movies,
837974
create_sync_gifs=args.create_sync_gifs,
975+
sync_gif_options=sync_gif_options,
976+
export_pptx=args.export_pptx,
977+
pptx_options=pptx_options,
838978
movie_types=tuple(args.movie_types),
839979
movie_fps=args.movie_fps,
840980
movie_bit_depth=args.movie_bit_depth,
@@ -852,6 +992,8 @@ def main():
852992
logger.info(f"Workers: {config.num_workers}")
853993

854994
result = process_plate(config)
995+
if config.export_pptx:
996+
export_powerpoint_deck(config, result)
855997

856998
logger.info("=" * 50)
857999
logger.info("Processing complete!")
@@ -864,6 +1006,7 @@ def main():
8641006
logger.info(f" Movies: {len(result.movie_outputs)}")
8651007
logger.info(f" Sync GIFs: {len(result.sync_gif_outputs)}")
8661008
logger.info(f" Mosaics: {len(result.mosaic_outputs)}")
1009+
logger.info(f" Decks: {len(result.deck_outputs)}")
8671010

8681011

8691012
if __name__ == "__main__":
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Slide deck export subsystem for orthogonal projection outputs."""
2+
3+
from .assets import collect_figure_assets
4+
from .models import (
5+
DeckExportOptions,
6+
DeckExportResult,
7+
FigureAsset,
8+
FigureAssetCollection,
9+
SlideContentSpec,
10+
SlideLayoutOptions,
11+
)
12+
from .service import export_slide_deck
13+
14+
__all__ = [
15+
"DeckExportOptions",
16+
"DeckExportResult",
17+
"FigureAsset",
18+
"FigureAssetCollection",
19+
"SlideContentSpec",
20+
"SlideLayoutOptions",
21+
"collect_figure_assets",
22+
"export_slide_deck",
23+
]
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Asset collection helpers for slide export."""
2+
3+
from pathlib import Path
4+
from typing import Dict, Iterable, Tuple
5+
6+
from ..io_handler import AnimatedGifOutput, MovieOutput
7+
from .models import FigureAsset, FigureAssetCollection
8+
9+
10+
def _build_composite_assets(
11+
composite_outputs: Iterable[Path],
12+
) -> Tuple[FigureAsset, ...]:
13+
assets = []
14+
for path in sorted(composite_outputs):
15+
well_id = path.stem.replace("_composite", "")
16+
assets.append(
17+
FigureAsset(
18+
well_id=well_id,
19+
asset_type="composite_png",
20+
path=path,
21+
title=f"Well {well_id}",
22+
caption="Composite orthogonal projection",
23+
)
24+
)
25+
return tuple(assets)
26+
27+
28+
def _build_sync_gif_assets(
29+
sync_gif_outputs: Iterable[AnimatedGifOutput],
30+
) -> Tuple[FigureAsset, ...]:
31+
assets = []
32+
for output in sorted(sync_gif_outputs, key=lambda item: item.well_id):
33+
assets.append(
34+
FigureAsset(
35+
well_id=output.well_id,
36+
asset_type="sync_gif",
37+
path=output.output_path,
38+
title=f"Well {output.well_id}",
39+
caption="Synchronized orthogonal GIF",
40+
)
41+
)
42+
return tuple(assets)
43+
44+
45+
def _build_movie_assets(
46+
movie_outputs: Iterable[MovieOutput],
47+
) -> Tuple[FigureAsset, ...]:
48+
assets = []
49+
for output in sorted(
50+
movie_outputs, key=lambda item: (item.well_id, item.movie_type)
51+
):
52+
if output.channel_id != "composite":
53+
continue
54+
assets.append(
55+
FigureAsset(
56+
well_id=output.well_id,
57+
asset_type="movie_mp4",
58+
path=output.output_path,
59+
title=f"Well {output.well_id}",
60+
caption=f"{output.movie_type.upper()} movie",
61+
)
62+
)
63+
return tuple(assets)
64+
65+
66+
def collect_figure_assets(
67+
composite_outputs: Iterable[Path],
68+
sync_gif_outputs: Iterable[AnimatedGifOutput],
69+
movie_outputs: Iterable[MovieOutput],
70+
) -> FigureAssetCollection:
71+
"""Normalize generated figure outputs for slide export."""
72+
return FigureAssetCollection(
73+
composites=_build_composite_assets(composite_outputs),
74+
sync_gifs=_build_sync_gif_assets(sync_gif_outputs),
75+
movies=_build_movie_assets(movie_outputs),
76+
)
77+
78+
79+
def select_assets_for_export(
80+
collection: FigureAssetCollection,
81+
include_composites: bool,
82+
include_sync_gifs: bool,
83+
include_movies: bool,
84+
) -> Tuple[FigureAsset, ...]:
85+
"""Select assets according to export options."""
86+
selected = []
87+
if include_composites:
88+
selected.extend(collection.composites)
89+
if include_sync_gifs:
90+
selected.extend(collection.sync_gifs)
91+
if include_movies:
92+
selected.extend(collection.movies)
93+
return tuple(selected)
94+
95+
96+
def index_assets_by_well(
97+
assets: Iterable[FigureAsset],
98+
) -> Dict[str, Tuple[FigureAsset, ...]]:
99+
"""Group normalized assets by well id."""
100+
grouped: Dict[str, list[FigureAsset]] = {}
101+
for asset in assets:
102+
grouped.setdefault(asset.well_id, []).append(asset)
103+
return {well_id: tuple(items) for well_id, items in sorted(grouped.items())}

0 commit comments

Comments
 (0)