twhy 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- twhy/__init__.py +20 -0
- twhy/__main__.py +4 -0
- twhy/_validation.py +23 -0
- twhy/_version.py +18 -0
- twhy/annotation_identity.py +48 -0
- twhy/channel_colors.py +71 -0
- twhy/cli.py +325 -0
- twhy/compositing.py +65 -0
- twhy/geojson_clip.py +127 -0
- twhy/glyph_atlas.py +148 -0
- twhy/image_clip.py +124 -0
- twhy/image_document.py +145 -0
- twhy/image_input.py +310 -0
- twhy/image_pyramid.py +58 -0
- twhy/intensity_window.py +81 -0
- twhy/pdf_text.py +63 -0
- twhy/pdf_workers.py +152 -0
- twhy/png_clip.py +135 -0
- twhy/py.typed +0 -0
- twhy/resampling.py +75 -0
- twhy/runtime_overview.py +256 -0
- twhy/sampled_overview.py +122 -0
- twhy/selection_geometry.py +127 -0
- twhy/source_input.py +206 -0
- twhy/startup_overview.py +49 -0
- twhy/structured_rendering.py +141 -0
- twhy/terminal_capabilities.py +58 -0
- twhy/text_clip.py +124 -0
- twhy/text_image.py +141 -0
- twhy/text_renderer.py +124 -0
- twhy/view_info.py +68 -0
- twhy/view_session.py +462 -0
- twhy/viewer/__init__.py +6 -0
- twhy/viewer/bindings.py +296 -0
- twhy/viewer/clipping.py +488 -0
- twhy/viewer/controls.py +272 -0
- twhy/viewer/file_viewer.py +117 -0
- twhy/viewer/mode_navigation.py +109 -0
- twhy/viewer/navigation.py +404 -0
- twhy/viewer/rendering.py +69 -0
- twhy/viewer/selection.py +266 -0
- twhy/viewer/selection_set.py +189 -0
- twhy/viewer/viewer.py +800 -0
- twhy/viewport.py +79 -0
- twhy-0.2.0.dist-info/METADATA +228 -0
- twhy-0.2.0.dist-info/RECORD +50 -0
- twhy-0.2.0.dist-info/WHEEL +5 -0
- twhy-0.2.0.dist-info/entry_points.txt +2 -0
- twhy-0.2.0.dist-info/licenses/LICENSE +201 -0
- twhy-0.2.0.dist-info/top_level.txt +1 -0
twhy/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Reusable image-to-text rendering. The terminal UI is imported separately."""
|
|
2
|
+
|
|
3
|
+
from ._version import resolve_version
|
|
4
|
+
from .compositing import ChannelStyle, compose_channels
|
|
5
|
+
from .image_input import open_image
|
|
6
|
+
from .intensity_window import IntensityWindow, estimate_window
|
|
7
|
+
from .view_info import ReadBudgetExceeded
|
|
8
|
+
from .resampling import resample_image
|
|
9
|
+
from .text_image import TextImage
|
|
10
|
+
from .text_renderer import TextRenderer
|
|
11
|
+
from .view_session import ViewSession
|
|
12
|
+
from .viewport import Viewport
|
|
13
|
+
|
|
14
|
+
__version__ = resolve_version()
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ChannelStyle", "IntensityWindow", "ReadBudgetExceeded",
|
|
18
|
+
"TextImage", "TextRenderer", "ViewSession", "Viewport", "__version__",
|
|
19
|
+
"compose_channels", "estimate_window", "open_image", "resample_image",
|
|
20
|
+
]
|
twhy/__main__.py
ADDED
twhy/_validation.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Small shared validation barriers for public numerical operations."""
|
|
2
|
+
|
|
3
|
+
from numbers import Integral
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import ArrayLike, NDArray
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def positive_integer(value: int, name: str) -> int:
|
|
10
|
+
if isinstance(value, bool) or not isinstance(value, Integral) or value < 1:
|
|
11
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
12
|
+
return int(value)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def image_array(image: ArrayLike) -> NDArray:
|
|
16
|
+
value = np.asarray(image)
|
|
17
|
+
if value.ndim not in (2, 3) or (value.ndim == 3 and value.shape[-1] != 3):
|
|
18
|
+
raise ValueError(f"Expected a YX grayscale or YXS RGB image; got {value.shape}")
|
|
19
|
+
if any(size == 0 for size in value.shape):
|
|
20
|
+
raise ValueError("Image dimensions must be nonzero")
|
|
21
|
+
if value.dtype.kind not in "buif":
|
|
22
|
+
raise TypeError(f"Image must have a real numeric dtype; got {value.dtype}")
|
|
23
|
+
return value
|
twhy/_version.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Resolve the one authored version from the checkout or distribution metadata."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_version() -> str:
|
|
9
|
+
project_file = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
10
|
+
if project_file.is_file():
|
|
11
|
+
with project_file.open("rb") as stream:
|
|
12
|
+
project = tomllib.load(stream).get("project", {})
|
|
13
|
+
if project.get("name") == "twhy":
|
|
14
|
+
return str(project["version"])
|
|
15
|
+
try:
|
|
16
|
+
return version("twhy")
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
return "0+unknown"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Random human labels and UUIDv4 identities, independent of all image metadata."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def random_name() -> str:
|
|
8
|
+
# Lazy import: numerical rendering never imports the naming library.
|
|
9
|
+
try:
|
|
10
|
+
from coolname import generate_slug
|
|
11
|
+
except ImportError as error:
|
|
12
|
+
raise RuntimeError(
|
|
13
|
+
"Selection naming requires coolname; reinstall twhy's dependencies"
|
|
14
|
+
) from error
|
|
15
|
+
return generate_slug(3)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AnnotationIdentity:
|
|
20
|
+
id: str
|
|
21
|
+
name: str
|
|
22
|
+
name_generated: bool = True
|
|
23
|
+
|
|
24
|
+
def renamed(self, name: str) -> "AnnotationIdentity":
|
|
25
|
+
name = name.strip()
|
|
26
|
+
if not name or not name.isprintable():
|
|
27
|
+
raise ValueError("Name must be nonempty, printable text")
|
|
28
|
+
if name == self.name:
|
|
29
|
+
return self
|
|
30
|
+
return replace(self, name=name, name_generated=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class IdentityFactory:
|
|
34
|
+
"""Avoid generated-name repeats in one viewer; UUID remains the identifier."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.used_names: set[str] = set()
|
|
38
|
+
|
|
39
|
+
def create(self) -> AnnotationIdentity:
|
|
40
|
+
for _ in range(1000):
|
|
41
|
+
name = random_name()
|
|
42
|
+
# coolname's count excludes linking words and some animal names are
|
|
43
|
+
# compound. Keep exactly three short slug components for the footer.
|
|
44
|
+
if (len(name.split("-")) == 3 and len(name) <= 32 and name not in self.used_names
|
|
45
|
+
and all(part.isascii() and part.isalpha() for part in name.split("-"))):
|
|
46
|
+
self.used_names.add(name)
|
|
47
|
+
return AnnotationIdentity(str(uuid4()), name)
|
|
48
|
+
raise RuntimeError("Could not generate a fresh short name; try selecting again")
|
twhy/channel_colors.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Display-only channel colors shared by the viewer and snapshot frontend."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from numbers import Integral
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from .compositing import CHANNEL_COLORS, ChannelStyle
|
|
9
|
+
|
|
10
|
+
RGBColor = tuple[float, float, float]
|
|
11
|
+
# A small explicit palette, not a CSS parser. Green is the additive RGB primary.
|
|
12
|
+
_COLOR_NAMES = {
|
|
13
|
+
"red": "FF0000", "green": "00FF00", "blue": "0000FF",
|
|
14
|
+
"cyan": "00FFFF", "magenta": "FF00FF", "yellow": "FFFF00",
|
|
15
|
+
"orange": "FF8000", "purple": "8000FF", "violet": "8000FF",
|
|
16
|
+
"white": "FFFFFF", "black": "000000", "gray": "808080", "grey": "808080",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_color(text: str) -> RGBColor | None:
|
|
21
|
+
"""Parse hex or a named color; None explicitly requests the channel default."""
|
|
22
|
+
if not isinstance(text, str):
|
|
23
|
+
raise ValueError("Color must be a hex string, color name, or default")
|
|
24
|
+
value = text.strip().casefold()
|
|
25
|
+
if value == "default":
|
|
26
|
+
return None
|
|
27
|
+
value = _COLOR_NAMES.get(value, value)
|
|
28
|
+
if not re.fullmatch(r"#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})", value):
|
|
29
|
+
raise ValueError("Use #RGB, #RRGGBB, a color name (e.g. blue/cyan/white), or default")
|
|
30
|
+
value = value.removeprefix("#")
|
|
31
|
+
if len(value) == 3:
|
|
32
|
+
value = "".join(char * 2 for char in value)
|
|
33
|
+
return tuple(int(value[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def color_hex(color: Sequence[float]) -> str:
|
|
37
|
+
"""Display a normalized color as uppercase 8-bit RGB hex."""
|
|
38
|
+
return "#" + "".join(f"{round(component * 255):02X}" for component in color)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_color(index: int, channel_count: int, *, is_rgb: bool = False) -> RGBColor:
|
|
42
|
+
"""Preserve the existing palette exactly, including white single-channel views."""
|
|
43
|
+
return ((1.0, 1.0, 1.0) if is_rgb or channel_count == 1 else
|
|
44
|
+
CHANNEL_COLORS[index % len(CHANNEL_COLORS)])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def channel_palette(channel_count: int, *, is_rgb: bool = False,
|
|
48
|
+
overrides: Mapping[int, str] | None = None) -> list[RGBColor]:
|
|
49
|
+
"""Own a per-source palette; overrides use source indices, not selection order."""
|
|
50
|
+
colors = [default_color(i, channel_count, is_rgb=is_rgb) for i in range(channel_count)]
|
|
51
|
+
if is_rgb and overrides:
|
|
52
|
+
raise ValueError("Channel pseudocolors apply only to scalar intensity channels, "
|
|
53
|
+
"not RGB images or PDF pages")
|
|
54
|
+
for index, text in (overrides or {}).items():
|
|
55
|
+
if isinstance(index, bool) or not isinstance(index, Integral):
|
|
56
|
+
raise ValueError("Channel-color indices must be integers")
|
|
57
|
+
if not 0 <= index < channel_count:
|
|
58
|
+
raise ValueError(f"Channel-color index {index} is outside 0..{channel_count - 1}")
|
|
59
|
+
color = parse_color(text)
|
|
60
|
+
if color is not None:
|
|
61
|
+
colors[index] = color
|
|
62
|
+
return colors
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def apply_channel_colors(styles: Sequence[ChannelStyle], channels: Sequence[int],
|
|
66
|
+
colors: Sequence[RGBColor] | None) -> tuple[ChannelStyle, ...]:
|
|
67
|
+
"""Keep the prepared windows; attach each sampled channel's current pseudocolor."""
|
|
68
|
+
if colors is None:
|
|
69
|
+
return tuple(styles)
|
|
70
|
+
return tuple(replace(style, color=colors[index])
|
|
71
|
+
for style, index in zip(styles, channels, strict=True))
|
twhy/cli.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Click front door for the same sampling and rendering library used by the TUI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from tempfile import NamedTemporaryFile
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from prompt_toolkit.output import ColorDepth
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .channel_colors import apply_channel_colors, channel_palette, parse_color
|
|
14
|
+
from .image_input import image_kind, open_image, pdf_raster_shape
|
|
15
|
+
from .pdf_workers import DEFAULT_PDF_MAX_DPI, pdf_dpi_steps
|
|
16
|
+
from .intensity_window import IntensityWindow
|
|
17
|
+
from .terminal_capabilities import available_color_depth, resolve_auto_mode
|
|
18
|
+
from .text_renderer import RENDER_MODES, TextRenderer
|
|
19
|
+
from .text_clip import CLIPBOARD_MODES, CLIP_SOURCES
|
|
20
|
+
from .view_session import ViewSession
|
|
21
|
+
from .runtime_overview import DEFAULT_OVERVIEW_CACHE_MIB, DEFAULT_OVERVIEW_MAX_SOURCE_PIXELS
|
|
22
|
+
from .startup_overview import (DEFAULT_STARTUP_OVERVIEW_SECONDS, prepare_fast_overview,
|
|
23
|
+
startup_fit)
|
|
24
|
+
from .viewport import Viewport
|
|
25
|
+
from .source_input import DEFAULT_MAX_REMOTE_MIB, resolve_input
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_text(path: Path, text: str, *, overwrite: bool) -> None:
|
|
29
|
+
"""Commit a complete UTF-8 snapshot without leaving partial output on failure."""
|
|
30
|
+
temporary = None
|
|
31
|
+
try:
|
|
32
|
+
with NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=path.parent,
|
|
33
|
+
prefix=f".{path.name}.", suffix=".tmp", delete=False) as handle:
|
|
34
|
+
temporary = Path(handle.name)
|
|
35
|
+
handle.write(text)
|
|
36
|
+
if overwrite:
|
|
37
|
+
os.replace(temporary, path)
|
|
38
|
+
else:
|
|
39
|
+
# Exclusive publication also protects against a destination created
|
|
40
|
+
# after the preflight check. Both paths are on the same filesystem.
|
|
41
|
+
os.link(temporary, path)
|
|
42
|
+
finally:
|
|
43
|
+
if temporary is not None:
|
|
44
|
+
temporary.unlink(missing_ok=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.command(context_settings={"show_default": True})
|
|
48
|
+
@click.argument("input_path", type=str)
|
|
49
|
+
@click.version_option(__version__)
|
|
50
|
+
@click.option("--series", type=click.IntRange(min=0), default=0,
|
|
51
|
+
help="Zero-based OME-TIFF/SVS/OME-Zarr image series.")
|
|
52
|
+
@click.option("--group", default="", help="OME-Zarr group under the store root (plate/well/etc.).")
|
|
53
|
+
@click.option("--z-index", type=click.IntRange(min=0), help="OME-Zarr Z plane, zero-based.")
|
|
54
|
+
@click.option("--t-index", type=click.IntRange(min=0), help="OME-Zarr T plane, zero-based.")
|
|
55
|
+
@click.option("--input-format", type=click.Choice(["auto", "zarr"]), default="auto",
|
|
56
|
+
help="Use zarr for URL store roots without a .zarr suffix.")
|
|
57
|
+
@click.option("--max-remote-mib", type=click.FloatRange(min=0, min_open=True),
|
|
58
|
+
default=DEFAULT_MAX_REMOTE_MIB,
|
|
59
|
+
help="Maximum whole-file HTTP(S)/S3 download in MiB; Zarr stays regional.")
|
|
60
|
+
@click.option("--s3-endpoint-url", help="Optional HTTP(S) origin for S3-compatible storage.")
|
|
61
|
+
@click.option("--s3-anonymous", is_flag=True, help="Unsigned reads for public S3 buckets.")
|
|
62
|
+
@click.option("--page", type=click.IntRange(min=1),
|
|
63
|
+
help="One-based PDF page to display (default: 1).")
|
|
64
|
+
@click.option("--frame", "frame_index", type=click.IntRange(min=0),
|
|
65
|
+
help="Zero-based raster frame, for GIF/APNG/WebP (default: 0; no playback).")
|
|
66
|
+
@click.option("--pdf-max-dpi", type=click.FloatRange(min=0, min_open=True),
|
|
67
|
+
help="PDF detail ceiling (default: 600 DPI). Viewer opens at up to 144 DPI, "
|
|
68
|
+
"then refines through 300/600 and the ceiling. Snapshots use the ceiling.")
|
|
69
|
+
@click.option("--pdf-cache-pages", type=click.IntRange(min=1),
|
|
70
|
+
help="PDF page pyramids retained in RAM (default: 3; 1 disables prefetch).")
|
|
71
|
+
@click.option("--background", type=click.Choice(["white", "black"]),
|
|
72
|
+
help="Matte for raster transparency and PDF pages (default: white).")
|
|
73
|
+
@click.option("--channel", "channels", type=click.IntRange(min=0), multiple=True, default=(0,),
|
|
74
|
+
help="Zero-based logical channel. Repeat to composite multiple markers.")
|
|
75
|
+
@click.option("--channel-color", "channel_color_entries", type=(click.IntRange(min=0), str),
|
|
76
|
+
multiple=True, metavar="INDEX COLOR",
|
|
77
|
+
help="Intensity-channel pseudocolor: #RGB, #RRGGBB, name, or default. Repeat per "
|
|
78
|
+
"zero-based source channel; viewer and ANSI snapshots. Not for RGB/PDF.")
|
|
79
|
+
@click.option("--lower", type=float,
|
|
80
|
+
help="Lower display limit in raw intensity units; pair with --upper.")
|
|
81
|
+
@click.option("--upper", type=float,
|
|
82
|
+
help="Upper display limit in raw intensity units; pair with --lower.")
|
|
83
|
+
@click.option("--mode", type=click.Choice(["auto", *RENDER_MODES, "ascii"]), default="auto",
|
|
84
|
+
help="auto: colored half-blocks when supported, simple otherwise; "
|
|
85
|
+
"detail/shape: text art; simple/ascii: legacy density; blocks: half-blocks.")
|
|
86
|
+
@click.option("--color/--no-color", default=None,
|
|
87
|
+
help="Use RGB/pseudocolor (auto: terminal-aware in viewer; off for plain snapshots).")
|
|
88
|
+
@click.option("--invert/--no-invert", default=None,
|
|
89
|
+
help="Reverse text ink density (auto: on for RGB, off for fluorescence).")
|
|
90
|
+
@click.option("--cell-aspect", type=click.FloatRange(min=0, min_open=True), default=2.0,
|
|
91
|
+
help="Terminal cell height divided by width.")
|
|
92
|
+
@click.option("--region", type=float, nargs=4, metavar="X Y WIDTH HEIGHT",
|
|
93
|
+
help="Initial viewport in base-image pixels instead of fitting. PDF coordinates use "
|
|
94
|
+
"the initial preview grid (up to 144 DPI), also for snapshots.")
|
|
95
|
+
@click.option("--cache-mib", type=click.FloatRange(min=0), default=32.0,
|
|
96
|
+
help="Maximum retained resampled-preview cache size; decoder storage is separate.")
|
|
97
|
+
@click.option("--max-read-pixels", type=click.IntRange(min=1), default=4194304,
|
|
98
|
+
help="Maximum source samples per request, counting selected channels/RGB samples.")
|
|
99
|
+
@click.option("--overview-cache-mib", type=click.FloatRange(min=0),
|
|
100
|
+
default=DEFAULT_OVERVIEW_CACHE_MIB,
|
|
101
|
+
help="RAM for completed/in-progress full-coverage display overviews; 0 disables. "
|
|
102
|
+
"Separate from preview cache; viewer and snapshots.")
|
|
103
|
+
@click.option("--overview-max-source-pixels", type=click.IntRange(min=0),
|
|
104
|
+
default=DEFAULT_OVERVIEW_MAX_SOURCE_PIXELS,
|
|
105
|
+
help="Total native samples allowed in an overview scan, including channels/RGB. "
|
|
106
|
+
"Separate from each chunk's --max-read-pixels limit; 0 disables.")
|
|
107
|
+
@click.option("--startup-overview-seconds", type=click.FloatRange(min=0),
|
|
108
|
+
default=DEFAULT_STARTUP_OVERVIEW_SECONDS,
|
|
109
|
+
help="Attempt a quick whole-slide cache before first view/snapshot (default: 2s); "
|
|
110
|
+
"0 skips startup scanning. Native reads can exceed a deadline.")
|
|
111
|
+
@click.option("--snapshot", is_flag=True, help="Render one text frame without starting the viewer.")
|
|
112
|
+
@click.option("--columns", type=click.IntRange(min=1), default=120,
|
|
113
|
+
help="Snapshot width in characters.")
|
|
114
|
+
@click.option("--rows", type=click.IntRange(min=1), default=40,
|
|
115
|
+
help="Snapshot height in characters.")
|
|
116
|
+
@click.option("--ansi", is_flag=True, help="Allow ANSI color escape sequences in a snapshot.")
|
|
117
|
+
@click.option("--output-path", "-o", type=click.Path(dir_okay=False, path_type=Path),
|
|
118
|
+
help="Snapshot destination; omitted means stdout. Requires --snapshot.")
|
|
119
|
+
@click.option("--output-text-clip", type=click.Path(dir_okay=False, path_type=Path),
|
|
120
|
+
help=("Interactive Copy/Save destination for the latest text clip; "
|
|
121
|
+
"no file until used."))
|
|
122
|
+
@click.option("--output-ansi-clip", type=click.Path(dir_okay=False, path_type=Path),
|
|
123
|
+
help="Interactive ANSI artwork clip destination for terminals.")
|
|
124
|
+
@click.option("--output-png-clip", type=click.Path(dir_okay=False, path_type=Path),
|
|
125
|
+
help="Interactive image clip destination; PNG preserves colored blocks for sharing.")
|
|
126
|
+
@click.option("--output-geojson-clip", type=click.Path(dir_okay=False, path_type=Path),
|
|
127
|
+
help="Interactive GeoJSON destination for rectangle or polygon selections.")
|
|
128
|
+
@click.option("--clipboard", type=click.Choice(CLIPBOARD_MODES), default=None,
|
|
129
|
+
help="Copy transport: auto (local helper or OSC 52), osc52, or off (save only).")
|
|
130
|
+
@click.option("--clip-source", type=click.Choice(CLIP_SOURCES), default=None,
|
|
131
|
+
help="Text clips: auto (embedded PDF words, otherwise screen glyphs) or screen.")
|
|
132
|
+
@click.option("--no-overwrite", is_flag=True,
|
|
133
|
+
help="Refuse to replace an existing snapshot or clip destination.")
|
|
134
|
+
def main(
|
|
135
|
+
input_path: str, series: int, group: str, z_index: int | None,
|
|
136
|
+
t_index: int | None, input_format: str, max_remote_mib: float,
|
|
137
|
+
s3_endpoint_url: str | None, s3_anonymous: bool, page: int | None, frame_index: int | None,
|
|
138
|
+
pdf_max_dpi: float | None, pdf_cache_pages: int | None,
|
|
139
|
+
background: str | None, channels: tuple[int, ...], lower: float | None,
|
|
140
|
+
channel_color_entries: tuple[tuple[int, str], ...],
|
|
141
|
+
upper: float | None, mode: str, color: bool | None, invert: bool | None,
|
|
142
|
+
cell_aspect: float, region: tuple[float, ...] | None, cache_mib: float,
|
|
143
|
+
max_read_pixels: int, overview_cache_mib: float, overview_max_source_pixels: int,
|
|
144
|
+
startup_overview_seconds: float, snapshot: bool, columns: int, rows: int, ansi: bool,
|
|
145
|
+
output_path: Path | None, no_overwrite: bool,
|
|
146
|
+
output_text_clip: Path | None, output_ansi_clip: Path | None,
|
|
147
|
+
output_png_clip: Path | None, output_geojson_clip: Path | None,
|
|
148
|
+
clipboard: str | None, clip_source: str | None,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Explore OME-TIFF, Aperio SVS, ordinary images, or PDFs as terminal text.
|
|
151
|
+
|
|
152
|
+
Wheel/+/- zoom, arrows or mouse drag pan, c toggles the channel panel.
|
|
153
|
+
PgUp/PgDn or [/] flip PDF pages/OME series; p jumps to a specific page/series.
|
|
154
|
+
l/u/g edit limits and gamma; h edits channel color.
|
|
155
|
+
m cycles artwork modes (color or mono); M cycles strict ASCII; t toggles color.
|
|
156
|
+
s selects a rectangle; v starts a polygon; j copies GeoJSON; J saves it.
|
|
157
|
+
Selection: y/Y text, i/I PNG, t/T ANSI, j/J GeoJSON (copy/save).
|
|
158
|
+
"""
|
|
159
|
+
channel_colors = {}
|
|
160
|
+
for index, text in channel_color_entries:
|
|
161
|
+
try:
|
|
162
|
+
parse_color(text)
|
|
163
|
+
if index in channel_colors:
|
|
164
|
+
raise ValueError(f"Channel {index} has more than one color override")
|
|
165
|
+
except ValueError as error:
|
|
166
|
+
raise click.BadParameter(str(error), param_hint="--channel-color") from error
|
|
167
|
+
channel_colors[index] = text
|
|
168
|
+
if (lower is None) != (upper is None):
|
|
169
|
+
raise click.UsageError("--lower and --upper must be supplied together")
|
|
170
|
+
if not snapshot and (output_path is not None or ansi):
|
|
171
|
+
raise click.UsageError("--output-path and --ansi require --snapshot")
|
|
172
|
+
if snapshot and any(v is not None for v in
|
|
173
|
+
(output_text_clip, output_ansi_clip, output_png_clip,
|
|
174
|
+
output_geojson_clip, clipboard, clip_source)):
|
|
175
|
+
raise click.UsageError("Clip options apply only to interactive viewing")
|
|
176
|
+
if snapshot and pdf_cache_pages is not None:
|
|
177
|
+
raise click.UsageError("--pdf-cache-pages applies only to interactive PDF viewing")
|
|
178
|
+
if snapshot and no_overwrite and output_path is None:
|
|
179
|
+
raise click.UsageError("--no-overwrite requires --output-path")
|
|
180
|
+
if snapshot and color is True and not ansi:
|
|
181
|
+
raise click.UsageError("Colored snapshots require --ansi")
|
|
182
|
+
if snapshot and mode == "blocks" and not ansi:
|
|
183
|
+
raise click.UsageError(
|
|
184
|
+
"Half-block snapshots require --ansi; use detail, shape, or simple for plain text"
|
|
185
|
+
)
|
|
186
|
+
if not snapshot and (not sys.stdin.isatty() or not sys.stdout.isatty()):
|
|
187
|
+
raise click.UsageError(
|
|
188
|
+
"Interactive viewing requires a terminal; use --snapshot for text output"
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
with resolve_input(input_path, max_remote_mib=max_remote_mib,
|
|
192
|
+
s3_endpoint_url=s3_endpoint_url, s3_anonymous=s3_anonymous,
|
|
193
|
+
input_format=input_format) as source:
|
|
194
|
+
if output_path is not None:
|
|
195
|
+
if isinstance(source.path, Path) and source.path.is_file() and (
|
|
196
|
+
output_path.resolve() == source.path.resolve() or
|
|
197
|
+
(output_path.exists() and os.path.samefile(source.path, output_path))
|
|
198
|
+
):
|
|
199
|
+
raise ValueError("The snapshot destination must not be the input image")
|
|
200
|
+
if no_overwrite and output_path.exists():
|
|
201
|
+
raise FileExistsError(f"Snapshot destination already exists: {output_path}")
|
|
202
|
+
if not output_path.parent.is_dir():
|
|
203
|
+
raise FileNotFoundError(
|
|
204
|
+
f"Output directory does not exist: {output_path.parent}"
|
|
205
|
+
)
|
|
206
|
+
depth = None if snapshot else available_color_depth()
|
|
207
|
+
use_color = (not snapshot or ansi) if color is None else color
|
|
208
|
+
# NO_COLOR and advertised one-bit terminals are genuinely monochrome,
|
|
209
|
+
# not merely a colored renderer whose SGR happens to get suppressed.
|
|
210
|
+
# The m cycle must therefore skip color-dependent half-blocks too.
|
|
211
|
+
if not snapshot and color is None and depth == ColorDepth.DEPTH_1_BIT:
|
|
212
|
+
use_color = False
|
|
213
|
+
if mode == "auto":
|
|
214
|
+
mode = resolve_auto_mode(
|
|
215
|
+
snapshot=snapshot, ansi=ansi, color=use_color,
|
|
216
|
+
is_tty=sys.stdout.isatty(), encoding=sys.stdout.encoding,
|
|
217
|
+
term=os.environ.get("TERM"),
|
|
218
|
+
depth=depth,
|
|
219
|
+
)
|
|
220
|
+
window = None if lower is None else IntensityWindow(lower, upper)
|
|
221
|
+
viewport = None if region is None else Viewport(*region)
|
|
222
|
+
path = source.path
|
|
223
|
+
kind = image_kind(path, input_format="zarr" if source.is_zarr else "auto")
|
|
224
|
+
name = source.label
|
|
225
|
+
if kind in {"tiff", "zarr"}:
|
|
226
|
+
name += f" | series {series}"
|
|
227
|
+
elif kind == "pdf":
|
|
228
|
+
dpi = pdf_dpi_steps(DEFAULT_PDF_MAX_DPI if pdf_max_dpi is None else pdf_max_dpi)[-1]
|
|
229
|
+
name += f" | page {page or 1} | {dpi:g} DPI raster"
|
|
230
|
+
elif frame_index is not None:
|
|
231
|
+
name += f" | frame {frame_index} (0-based)"
|
|
232
|
+
matte = None if background is None else ((255, 255, 255) if background == "white"
|
|
233
|
+
else (0, 0, 0))
|
|
234
|
+
if not snapshot:
|
|
235
|
+
from .viewer.file_viewer import view_file
|
|
236
|
+
|
|
237
|
+
asyncio.run(view_file(
|
|
238
|
+
path, series=series, page=page, frame=frame_index,
|
|
239
|
+
group=group, z_index=z_index, t_index=t_index,
|
|
240
|
+
input_format="zarr" if source.is_zarr else "auto",
|
|
241
|
+
s3_endpoint_url=s3_endpoint_url, s3_anonymous=s3_anonymous,
|
|
242
|
+
source_name=source.label,
|
|
243
|
+
pdf_max_dpi=pdf_max_dpi, pdf_cache_pages=pdf_cache_pages, background=matte,
|
|
244
|
+
channels=channels, channel_colors=channel_colors,
|
|
245
|
+
window=window, mode=mode, color=use_color, invert=invert,
|
|
246
|
+
cell_aspect=cell_aspect, viewport=viewport, cache_mib=cache_mib,
|
|
247
|
+
max_read_pixels=max_read_pixels, overview_cache_mib=overview_cache_mib,
|
|
248
|
+
overview_max_source_pixels=overview_max_source_pixels,
|
|
249
|
+
startup_overview_seconds=startup_overview_seconds,
|
|
250
|
+
output_text_clip=output_text_clip,
|
|
251
|
+
output_ansi_clip=output_ansi_clip, output_png_clip=output_png_clip,
|
|
252
|
+
output_geojson_clip=output_geojson_clip, clipboard=clipboard or "auto",
|
|
253
|
+
clip_source=clip_source or "auto", clip_overwrite=not no_overwrite,
|
|
254
|
+
))
|
|
255
|
+
return
|
|
256
|
+
if kind != "pdf" and pdf_max_dpi is not None:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
"--pdf-max-dpi applies only to PDF; TIFF/raster options do not apply"
|
|
259
|
+
)
|
|
260
|
+
with (
|
|
261
|
+
open_image(path, series=series, page=page, frame=frame_index,
|
|
262
|
+
pdf_dpi=dpi if kind == "pdf" else None, background=matte,
|
|
263
|
+
group=group, z_index=z_index, t_index=t_index,
|
|
264
|
+
s3_endpoint_url=s3_endpoint_url, s3_anonymous=s3_anonymous,
|
|
265
|
+
input_format="zarr" if source.is_zarr else "auto") as image,
|
|
266
|
+
ViewSession(image, name=name, cache_mib=cache_mib,
|
|
267
|
+
max_read_pixels=max_read_pixels, overview_cache_mib=overview_cache_mib,
|
|
268
|
+
overview_max_source_pixels=overview_max_source_pixels) as session,
|
|
269
|
+
):
|
|
270
|
+
if kind == "pdf" and viewport is not None:
|
|
271
|
+
# --region stays on the quick-preview coordinate grid even when
|
|
272
|
+
# a deterministic snapshot renders directly at the detail ceiling.
|
|
273
|
+
initial_dpi = pdf_dpi_steps(dpi)[0]
|
|
274
|
+
if initial_dpi != dpi:
|
|
275
|
+
height, width = pdf_raster_shape(path, page or 1, initial_dpi)
|
|
276
|
+
sy, sx = session.info.shape[0] / height, session.info.shape[1] / width
|
|
277
|
+
viewport = Viewport(viewport.x * sx, viewport.y * sy,
|
|
278
|
+
viewport.width * sx, viewport.height * sy)
|
|
279
|
+
selected = session._select_channels(channels)
|
|
280
|
+
colors = channel_palette(len(session.info.channels), is_rgb=session.info.is_rgb,
|
|
281
|
+
overrides=channel_colors)
|
|
282
|
+
renderer = TextRenderer(
|
|
283
|
+
mode=mode, color=use_color,
|
|
284
|
+
invert=session.info.is_rgb if invert is None else invert,
|
|
285
|
+
cell_aspect=cell_aspect,
|
|
286
|
+
)
|
|
287
|
+
whole_snapshot = viewport is None
|
|
288
|
+
if whole_snapshot:
|
|
289
|
+
# Snapshots depict the full extent by default, as does a quick
|
|
290
|
+
# interactive start. A costly overview can use a labelled sparse
|
|
291
|
+
# fallback rather than silently exporting a center crop.
|
|
292
|
+
viewport, native = startup_fit(session, renderer, columns=columns,
|
|
293
|
+
rows=rows, channels=selected)
|
|
294
|
+
if not native:
|
|
295
|
+
prepare_fast_overview(session, selected, seconds=startup_overview_seconds)
|
|
296
|
+
sample_rows, sample_columns = renderer.sample_shape(columns=columns, rows=rows)
|
|
297
|
+
plan = session.plan(viewport, rows=sample_rows, columns=sample_columns,
|
|
298
|
+
channels=selected, allow_sampled_overview=whole_snapshot)
|
|
299
|
+
if plan.level.approximate:
|
|
300
|
+
click.echo(
|
|
301
|
+
"Pyramid scale unknown: using approximate extent-fit display mapping.",
|
|
302
|
+
err=True,
|
|
303
|
+
)
|
|
304
|
+
samples = session.sample(plan)
|
|
305
|
+
if samples.plan.sampled_overview is not None:
|
|
306
|
+
click.echo(("Cached full-coverage overview: native coarse pixels were reduced "
|
|
307
|
+
"for this snapshot." if samples.cached_overview else
|
|
308
|
+
"Sampled overview: sparse navigational preview, not a complete "
|
|
309
|
+
"native reduction."), err=True)
|
|
310
|
+
styles = session.prepare_styles(samples, windows=[window] * len(selected))
|
|
311
|
+
styles = apply_channel_colors(styles, selected, colors)
|
|
312
|
+
frame = session.render(samples, styles=styles, renderer=renderer,
|
|
313
|
+
columns=columns, rows=rows)
|
|
314
|
+
text = frame.to_text(ansi=ansi) + "\n"
|
|
315
|
+
if output_path is None:
|
|
316
|
+
click.echo(text, nl=False, color=ansi)
|
|
317
|
+
else:
|
|
318
|
+
_write_text(output_path, text, overwrite=not no_overwrite)
|
|
319
|
+
except MemoryError as error:
|
|
320
|
+
raise click.ClickException(
|
|
321
|
+
"Not enough RAM to open/render this image. Ordinary images retain a decoded "
|
|
322
|
+
"raster and pyramid; for PDF, try a lower --pdf-max-dpi."
|
|
323
|
+
) from error
|
|
324
|
+
except (ValueError, LookupError, OSError, RuntimeError, EOFError) as error:
|
|
325
|
+
raise click.ClickException(str(error)) from error
|
twhy/compositing.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Display-only pseudocolor composition of independently windowed markers."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy.typing import ArrayLike, NDArray
|
|
8
|
+
|
|
9
|
+
from .intensity_window import IntensityWindow
|
|
10
|
+
|
|
11
|
+
# Deterministic display colors, not inferred marker identities.
|
|
12
|
+
CHANNEL_COLORS = (
|
|
13
|
+
(0.45, 0.65, 1.0),
|
|
14
|
+
(1.0, 0.35, 0.65),
|
|
15
|
+
(0.35, 1.0, 0.55),
|
|
16
|
+
(1.0, 0.85, 0.25),
|
|
17
|
+
(0.25, 1.0, 1.0),
|
|
18
|
+
(0.85, 0.45, 1.0),
|
|
19
|
+
(1.0, 0.60, 0.25),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class ChannelStyle:
|
|
25
|
+
"""A raw-intensity window and an additive RGB color in [0, 1]."""
|
|
26
|
+
|
|
27
|
+
window: IntensityWindow
|
|
28
|
+
color: Sequence[float] = (1.0, 1.0, 1.0)
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if not isinstance(self.window, IntensityWindow):
|
|
32
|
+
raise TypeError("window must be an IntensityWindow")
|
|
33
|
+
if len(self.color) != 3 or not all(np.isfinite(self.color)):
|
|
34
|
+
raise ValueError("color must contain three finite RGB values")
|
|
35
|
+
if any(x < 0 or x > 1 for x in self.color):
|
|
36
|
+
raise ValueError("RGB color components must be in [0, 1]")
|
|
37
|
+
# The record owns retained mutable input, rather than borrowing a list.
|
|
38
|
+
object.__setattr__(self, "color", tuple(self.color))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def compose_channels(
|
|
42
|
+
planes: ArrayLike, styles: Sequence[ChannelStyle]
|
|
43
|
+
) -> NDArray[np.float32]:
|
|
44
|
+
"""Window CYX planes, add their colors, and clip the composite into [0, 1].
|
|
45
|
+
|
|
46
|
+
Input planes and styles remain unchanged. Invalid samples contribute no
|
|
47
|
+
signal; a cell with no finite selected channel is marked NaN in all RGB
|
|
48
|
+
samples. Channel order must match the explicit styles sequence.
|
|
49
|
+
"""
|
|
50
|
+
values = np.asarray(planes)
|
|
51
|
+
if values.ndim != 3 or any(size == 0 for size in values.shape):
|
|
52
|
+
raise ValueError("planes must be a nonempty CYX array")
|
|
53
|
+
if values.dtype.kind not in "buif":
|
|
54
|
+
raise TypeError("planes must have a real numeric dtype")
|
|
55
|
+
if len(styles) != values.shape[0]:
|
|
56
|
+
raise ValueError("Provide exactly one style for each selected channel")
|
|
57
|
+
result = np.zeros((*values.shape[1:], 3), dtype=np.float32)
|
|
58
|
+
valid = np.zeros(values.shape[1:], dtype=bool)
|
|
59
|
+
for plane, style in zip(values, styles, strict=True):
|
|
60
|
+
normalized = style.window.apply(plane)
|
|
61
|
+
valid |= np.isfinite(normalized)
|
|
62
|
+
result += np.nan_to_num(normalized, nan=0.0)[..., None] * np.asarray(style.color)
|
|
63
|
+
np.clip(result, 0.0, 1.0, out=result)
|
|
64
|
+
result[~valid] = np.nan
|
|
65
|
+
return result
|