from __future__ import annotations
import os
from pathlib import Path
from typing import List, Literal, Union
import pandas as pd
__current_file_dir = Path(os.path.dirname(os.path.abspath(__file__)))
from skimage.io import imread
import math
from typing import Iterable, Tuple, TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from phenotypic import Image, GridImage
def _image_loader(
filepath, mode: Literal["array", "Image", "GridImage", "filepath"]
) -> Union[np.ndarray, Image, GridImage]:
from phenotypic import Image, GridImage
match mode:
case "array":
return imread(filepath)
case "Image":
return Image.imread(filepath)
case "GridImage":
return GridImage.imread(filepath)
case "filepath":
return filepath
case _:
raise ValueError(f"Unknown mode: {mode}")
[docs]
def make_synthetic_colony(
h: int = 256,
w: int = 256,
bit_depth: int = 8,
colony_rgb: Tuple[float, float, float] = (0.96, 0.88, 0.82),
agar_rgb: Tuple[float, float, float] = (0.55, 0.56, 0.54),
seed: int = 1,
) -> np.ndarray:
"""Generate a single bright fungal colony on solid-media agar. Returns an RGB NumPy array.
Args:
h: Image height (pixels).
w: Image width (pixels).
bit_depth: 8 or 16.
colony_rgb: Linear RGB in [0,1] for colony tint. Will be forced lighter than agar.
agar_rgb: Linear RGB in [0,1] for agar background.
seed: RNG seed.
Returns:
np.ndarray: HxWx3 RGB, dtype uint8 or uint16.
Notes:
- Colony is lighter than background via screen-like blend.
- No Petri dish. Scene is a cropped colony with padding on agar.
"""
if bit_depth not in (8, 16):
raise ValueError("bit_depth must be 8 or 16")
rng = np.random.default_rng(seed)
def _perlin_like(h: int, w: int, scales: Iterable[int]) -> np.ndarray:
acc = np.zeros((h, w), dtype=np.float32)
total = 0.0
for s in scales:
gh, gw = max(1, h // s), max(1, w // s)
g = rng.random((gh + 1, gw + 1)).astype(np.float32)
y = np.linspace(0, gh, h, endpoint=False)
x = np.linspace(0, gw, w, endpoint=False)
y0 = np.floor(y).astype(int)
x0 = np.floor(x).astype(int)
y1 = np.clip(y0 + 1, 0, gh)
x1 = np.clip(x0 + 1, 0, gw)
wy = y - y0
wx = x - x0
a = g[y0[:, None], x0[None, :]]
b = g[y0[:, None], x1[None, :]]
c = g[y1[:, None], x0[None, :]]
d = g[y1[:, None], x1[None, :]]
acc += (a * (1 - wx) + b * wx) * (1 - wy)[:, None] + (
c * (1 - wx) + d * wx
) * wy[:, None]
total += 1.0
acc = acc / max(total, 1e-6)
return (acc - acc.min()) / (np.ptp(acc) + 1e-6)
def _colony_mask(h: int, w: int, cy: float, cx: float, base_r: float) -> np.ndarray:
yy, xx = np.mgrid[0:h, 0:w]
theta = np.arctan2(yy - cy, xx - cx)
ntheta = 512
ang = np.linspace(-math.pi, math.pi, ntheta, endpoint=False)
radial_noise = 0.08 * rng.standard_normal(ntheta).astype(np.float32)
r_lookup = base_r * (
1.0 + np.interp(theta, ang, radial_noise, period=2 * math.pi)
)
d = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2)
edge_soft = max(base_r * 0.05, 1.0)
t = (r_lookup - d) / edge_soft
mask = np.clip(0.5 * (np.tanh(t) + 1.0), 0.0, 1.0)
tex = _perlin_like(h, w, scales=(32, 16, 8))
return np.clip(mask * (0.85 + 0.15 * tex), 0.0, 1.0)
# Agar background with mild texture
agar = np.array(agar_rgb, dtype=np.float32)
bg_tex = 0.025 * (_perlin_like(h, w, scales=(64, 32)) - 0.5)
bg = np.clip(agar[None, None, :] + bg_tex[..., None], 0.0, 1.0)
# Colony placement
cy, cx = h * 0.5, w * 0.5
r = min(h, w) * 0.35
m = _colony_mask(h, w, cy, cx, r)[..., None]
# Colony color, forced light
col = np.array(colony_rgb, dtype=np.float32)
col = np.clip(col, 0.86, 0.99)
# Screen-like blending inside colony mask to guarantee lighter-than-agar
colony_region = 1.0 - (1.0 - bg) * (1.0 - col[None, None, :])
img = bg * (1.0 - m) + colony_region * m
# Quantize
img = np.clip(img, 0.0, 1.0)
if bit_depth == 8:
return (img * 255.0 + 0.5).astype(np.uint8)
else:
return (img * 65535.0 + 0.5).astype(np.uint16)
[docs]
def load_synthetic_colony(
mode: Literal["array", "Image"] = "array",
) -> Union[np.ndarray, Image]:
"""
Loads synthetic colony data from a pre-saved file and returns it in the specified mode.
This function provides two modes for handling the synthetic colony data: 'array' and 'Image'.
Depending on the mode specified, it either returns the array directly or converts it into an
Image object. When 'Image' mode is selected, the object mask is also applied to the Image object.
Args:
mode (Literal['array', 'Image']): Specifies the format in which the synthetic colony
data should be returned. Use 'array' to return the raw data as an array or 'Image'
to return an Image object with the corresponding objmask.
Returns:
Union[np.ndarray, Image]: The synthetic colony data, either as a numpy array or an
Image object, depending on the specified mode.
Raises:
ValueError: If the mode is neither 'array' nor 'Image'.
Example:
.. dropdown:: Load synthetic colony data as a NumPy array or Image object
>>> from phenotypic.data import load_synthetic_colony
>>> img = load_synthetic_colony(mode='array')
"""
from phenotypic import Image
data = np.load(
Path(os.path.relpath(__current_file_dir / "synthetic_colony.npz", Path.cwd()))
)
match mode:
case "array":
return data["array"]
case "Image":
image = Image(data["array"])
image.objmask[:] = data["objmask"]
return image
case _:
raise ValueError("Invalid mode")
[docs]
def make_synthetic_plate(
nrows: int = 8,
ncols: int = 12,
plate_h: int = 2048,
plate_w: int = 3072,
bit_depth: int = 8,
colony_rgb: Tuple[float, float, float] = (0.96, 0.88, 0.82),
agar_rgb: Tuple[float, float, float] = (0.55, 0.56, 0.54),
seed: int = 1,
spacing_factor: float = 0.85,
colony_size_variation: float = 0.15,
) -> np.ndarray:
"""Generate a synthetic array plate with multiple colonies arranged in a grid.
Args:
nrows: Number of rows in the plate array (e.g., 8 for 96-well plate).
ncols: Number of columns in the plate array (e.g., 12 for 96-well plate).
plate_h: Total plate image height (pixels).
plate_w: Total plate image width (pixels).
bit_depth: 8 or 16.
colony_rgb: Linear RGB in [0,1] for colony tint. Will be forced lighter than agar.
agar_rgb: Linear RGB in [0,1] for agar background.
seed: RNG seed for reproducibility.
spacing_factor: Factor controlling spacing between colonies (0-1). Lower = more spacing.
colony_size_variation: Random variation in colony sizes (0-1). 0 = uniform size.
Returns:
np.ndarray: plate_h x plate_w x 3 RGB array, dtype uint8 or uint16.
Example:
# Create a standard 96-well plate (8x12)
plate = make_synthetic_plate(rows=8, cols=12, plate_h=2048, plate_w=3072)
# Create a 384-well plate (16x24)
plate = make_synthetic_plate(rows=16, cols=24, plate_h=2048, plate_w=3072)
"""
if bit_depth not in (8, 16):
raise ValueError("bit_depth must be 8 or 16")
rng = np.random.default_rng(seed)
def _perlin_like(h: int, w: int, scales: Iterable[int]) -> np.ndarray:
acc = np.zeros((h, w), dtype=np.float32)
total = 0.0
for s in scales:
gh, gw = max(1, h // s), max(1, w // s)
g = rng.random((gh + 1, gw + 1)).astype(np.float32)
y = np.linspace(0, gh, h, endpoint=False)
x = np.linspace(0, gw, w, endpoint=False)
y0 = np.floor(y).astype(int)
x0 = np.floor(x).astype(int)
y1 = np.clip(y0 + 1, 0, gh)
x1 = np.clip(x0 + 1, 0, gw)
wy = y - y0
wx = x - x0
a = g[y0[:, None], x0[None, :]]
b = g[y0[:, None], x1[None, :]]
c = g[y1[:, None], x0[None, :]]
d = g[y1[:, None], x1[None, :]]
acc += (a * (1 - wx) + b * wx) * (1 - wy)[:, None] + (
c * (1 - wx) + d * wx
) * wy[:, None]
total += 1.0
acc = acc / max(total, 1e-6)
return (acc - acc.min()) / (np.ptp(acc) + 1e-6)
def _colony_mask(h: int, w: int, cy: float, cx: float, base_r: float) -> np.ndarray:
yy, xx = np.mgrid[0:h, 0:w]
theta = np.arctan2(yy - cy, xx - cx)
ntheta = 512
ang = np.linspace(-math.pi, math.pi, ntheta, endpoint=False)
radial_noise = 0.08 * rng.standard_normal(ntheta).astype(np.float32)
r_lookup = base_r * (
1.0 + np.interp(theta, ang, radial_noise, period=2 * math.pi)
)
d = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2)
edge_soft = max(base_r * 0.05, 1.0)
t = (r_lookup - d) / edge_soft
mask = np.clip(0.5 * (np.tanh(t) + 1.0), 0.0, 1.0)
tex = _perlin_like(h, w, scales=(32, 16, 8))
return np.clip(mask * (0.85 + 0.15 * tex), 0.0, 1.0)
# Create agar background with texture
agar = np.array(agar_rgb, dtype=np.float32)
bg_tex = 0.025 * (_perlin_like(plate_h, plate_w, scales=(128, 64, 32)) - 0.5)
bg = np.clip(agar[None, None, :] + bg_tex[..., None], 0.0, 1.0)
# Calculate grid spacing
margin_y = plate_h / (nrows + 1)
margin_x = plate_w / (ncols + 1)
spacing_y = plate_h / (nrows + 1)
spacing_x = plate_w / (ncols + 1)
# Base colony radius
base_r = min(spacing_y, spacing_x) * spacing_factor * 0.5
# Colony color, forced light
col = np.array(colony_rgb, dtype=np.float32)
col = np.clip(col, 0.86, 0.99)
# Create mask for all colonies
img = bg.copy()
for row in range(nrows):
for col_idx in range(ncols):
# Calculate center position
cy = margin_y + row * spacing_y
cx = margin_x + col_idx * spacing_x
# Add small random offset
cy += rng.uniform(-spacing_y * 0.05, spacing_y * 0.05)
cx += rng.uniform(-spacing_x * 0.05, spacing_x * 0.05)
# Vary colony size
r = base_r * (
1.0 + rng.uniform(-colony_size_variation, colony_size_variation)
)
# Generate colony mask
m = _colony_mask(plate_h, plate_w, cy, cx, r)[..., None]
# Apply colony with screen-like blending
colony_region = 1.0 - (1.0 - img) * (1.0 - col[None, None, :])
img = img * (1.0 - m) + colony_region * m
# Quantize
img = np.clip(img, 0.0, 1.0)
if bit_depth == 8:
return (img * 255.0 + 0.5).astype(np.uint8)
else:
return (img * 65535.0 + 0.5).astype(np.uint16)
[docs]
def load_plate_12hr(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> Union[np.ndarray, Image, GridImage]:
"""Returns a plate image of a K. Marxianus colony 96 array plate at 12 hrs"""
return _image_loader(
Path(os.path.relpath(__current_file_dir / "StandardDay1.jpg", Path.cwd())), mode
)
[docs]
def load_plate_72hr(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> Union[np.ndarray, Image, GridImage]:
"""Return a image of a k. marxianus colony 96 array plate at 72 hrs"""
return _image_loader(
Path(os.path.relpath(__current_file_dir / "StandardDay6.jpg", Path.cwd())), mode
)
[docs]
def load_plate_series(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> List[Union[np.ndarray, Image, GridImage]]:
"""Return a series of plate images across 6 time samples"""
series = []
fnames = os.listdir(__current_file_dir / "PlateSeries")
fnames.sort()
for fname in fnames:
filepath = Path(
os.path.relpath(__current_file_dir / "PlateSeries" / fname, Path.cwd())
)
series.append(_image_loader(filepath, mode))
return series
[docs]
def load_early_colony(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> Union[np.ndarray, Image, GridImage]:
"""Returns a colony image array of K. Marxianus at 12 hrs"""
return _image_loader(
Path(os.path.relpath(__current_file_dir / "early_colony.png", Path.cwd())), mode
)
[docs]
def load_faint_early_colony(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> Union[np.ndarray, Image, GridImage]:
"""Returns a faint colony image array of K. Marxianus at 12 hrs"""
return _image_loader(
Path(
os.path.relpath(__current_file_dir / "early_colony_faint.png", Path.cwd())
),
mode,
)
[docs]
def load_colony(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> Union[np.ndarray, Image, GridImage]:
"""Returns a colony image array of K. Marxianus at 72 hrs"""
return _image_loader(
Path(os.path.relpath(__current_file_dir / "later_colony.png", Path.cwd())), mode
)
[docs]
def load_smear_plate_12hr(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> Union[np.ndarray, Image, GridImage]:
"""Returns a plate image array of K. Marxianus that contains noise such as smears"""
return _image_loader(
Path(os.path.relpath(__current_file_dir / "difficult/1_1S_16.jpg", Path.cwd())),
mode,
)
[docs]
def load_smear_plate_24hr(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> Union[np.ndarray, Image, GridImage]:
"""Returns a plate image array of K. Marxianus that contains noise such as smears"""
return _image_loader(
Path(os.path.relpath(__current_file_dir / "difficult/2_2Y_6.jpg", Path.cwd())),
mode,
)
def load_lactose_series(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> List[Union[np.ndarray, Image, GridImage]]:
"""Return a series of plate images across 6 time samples"""
series = []
fnames = os.listdir(__current_file_dir / "lactose")
fnames.sort()
for fname in fnames:
filepath = Path(
os.path.relpath(__current_file_dir / "lactose" / fname, Path.cwd())
)
series.append(_image_loader(filepath, mode))
return series
def yield_sample_dataset(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> Iterable[Union[np.ndarray, Image, GridImage]]:
"""Return a series of plate images across 6 time samples"""
fnames = [
x
for x in os.listdir(__current_file_dir / "PhenoTypicSampleSubset")
if x.endswith(".jpg")
]
fnames.sort()
for fname in fnames:
filepath = Path(
os.path.relpath(
__current_file_dir / "PhenoTypicSampleSubset" / fname, Path.cwd()
)
)
yield _image_loader(filepath, mode)
def load_meas() -> pd.DataFrame:
"""
Loads sample measurements for 3 strains using each of the measurement modules
Returns:
pd.DataFrame: A DataFrame containing the loaded measurement data.
"""
return pd.read_csv(
Path(os.path.relpath(__current_file_dir / "meas/all_meas.csv", Path.cwd())),
index_col=0,
)
def load_quickstart_meas() -> pd.DataFrame:
return pd.read_csv(
Path(
os.path.relpath(
__current_file_dir / "meas/GettingStartedMeas.csv", Path.cwd()
)
),
index_col=0,
)
def load_area_meas() -> pd.DataFrame:
"""
Loads sample measurements for 3 strains using area measurements
Returns:
pd.DataFrame: A DataFrame containing the sample area measurement data.
"""
return pd.read_csv(
Path(os.path.relpath(__current_file_dir / "meas/area_meas.csv", Path.cwd())),
index_col=0,
)
def load_imager_plate(
mode: Literal["array", "Image", "GridImage"] = "array",
) -> Union[np.ndarray, Image, GridImage]:
return _image_loader(
Path(os.path.relpath(__current_file_dir / "RHODOTORULA_RAW.cr3", Path.cwd())),
mode=mode,
)
[docs]
def load_synthetic_detection_image():
"""returns a phenotypic.GridImage of a synthetic plate with the colonies detected"""
import phenotypic
from skimage.io import imread
dirpath = Path(
os.path.relpath(__current_file_dir / "synthetic_test_plate", Path.cwd())
)
image = phenotypic.GridImage.imread(
filepath=dirpath / "circular_detect_plate_rgb.tif"
)
image.objmap[:] = imread(dirpath / "circular_detect_plate_objmap.png")
image.name = "Synthetic96PlateWithObjects"
return image