From 330abf8bbc07c29be736033bb4baed79fe1dc438 Mon Sep 17 00:00:00 2001
From: Christopher Rhodes <christopher.rhodes@embl.de>
Date: Tue, 26 Sep 2023 10:29:40 +0200
Subject: [PATCH] Roughed in chaetoceros project

---
 extensions/{examples => chaeo}/__init__.py    |   0
 extensions/chaeo/products.py                  |  97 ++++++++++++++++
 extensions/chaeo/test_zstack.py               |  31 +++++
 extensions/chaeo/zstack.py                    | 109 ++++++++++++++++++
 extensions/ilastik/examples/__init__.py       |   0
 .../{ => ilastik}/examples/ilastik3d.py       |   0
 extensions/ilastik/validators.py              |   5 +
 model_server/batch.py                         |  22 ++++
 model_server/process.py                       |  52 +++++++++
 readme.md                                     |  17 +++
 10 files changed, 333 insertions(+)
 rename extensions/{examples => chaeo}/__init__.py (100%)
 create mode 100644 extensions/chaeo/products.py
 create mode 100644 extensions/chaeo/test_zstack.py
 create mode 100644 extensions/chaeo/zstack.py
 create mode 100644 extensions/ilastik/examples/__init__.py
 rename extensions/{ => ilastik}/examples/ilastik3d.py (100%)
 create mode 100644 extensions/ilastik/validators.py
 create mode 100644 model_server/batch.py
 create mode 100644 model_server/process.py
 create mode 100644 readme.md

diff --git a/extensions/examples/__init__.py b/extensions/chaeo/__init__.py
similarity index 100%
rename from extensions/examples/__init__.py
rename to extensions/chaeo/__init__.py
diff --git a/extensions/chaeo/products.py b/extensions/chaeo/products.py
new file mode 100644
index 00000000..a0f50de3
--- /dev/null
+++ b/extensions/chaeo/products.py
@@ -0,0 +1,97 @@
+from PIL import Image, ImageDraw, ImageFont
+
+def draw_boxes_on_2d_image(img, boxes, **kwargs):
+    pilimg = Image.fromarray(np.copy(img))  # drawing modifies array in-place
+    draw = ImageDraw.Draw(pilimg)
+    font_size = kwargs.get('font_size', 18)
+    linewidth = kwargs.get('linewidth', 4)
+
+    draw.font = ImageFont.truetype(font="arial.ttf", size=font_size)
+
+    for box in boxes:
+        y0 = box['info'].y0
+        y1 = box['info'].y1
+        x0 = box['info'].x0
+        x1 = box['info'].x1
+        xm = round((x0 + x1) / 2)
+
+        la = box['info'].label
+        zi = box['info'].zi
+
+        draw.rectangle([(x0, y0), (x1, y1)], outline='white', width=linewidth)
+
+        if kwargs.get('add_label') is True:
+            draw.text((xm, y0), f'Z{zi:04d}-L{la:04d}', fill='white', anchor='mb')
+
+    return pilimg
+
+def generate_patches(
+        desc, stack, boxes, rescale_clip=0.0,
+        pad_to=256,
+        proj=lambda x: x.max(axis=0),
+        prefix='patch',
+        **kwargs
+):
+    patch_dir = root / 'output' / 'patches' / desc
+    patch_dir.mkdir(parents=True, exist_ok=True)
+
+    for box in boxes:
+        obj = box['info']
+        sl = box['slice']
+        rbb = box['relative_bounding_box']
+
+        patch = proj(stack[sl])
+        patch_fname = f'{prefix}-la{obj.label:04d}-zi{obj.zi:04d}'
+
+        if rescale_clip is not None:
+            patch = rescale(patch, rescale_clip)
+
+        if kwargs.get('draw_bounding_box') is True:
+            x0 = rbb['x0']
+            y0 = rbb['y0']
+            x1 = rbb['x1']
+            y1 = rbb['y1']
+
+            pilimg = Image.fromarray(patch)  # drawing modifies array in-place
+            draw = ImageDraw.Draw(pilimg)
+            draw.rectangle([(x0, y0), (x1, y1)], outline='white', width=kwargs.get('linewidth', 1))
+            patch = np.array(pilimg)
+
+        if pad_to:
+            patch = pad(patch, pad_to)
+
+        imsave(
+            patch_dir / (patch_fname + '.png'),
+            resample(patch),
+            check_contrast=False,
+        )
+    print(f'Successfully wrote {len(boxes)} patches to:\n{patch_dir}')
+
+
+def generate_3d_patches(  # in extensions.chaeo.products
+        desc, stack, boxes, rescale_clip=0.0,
+        pad_to=256,
+        prefix='patch',
+        proj=lambda x: x,
+):
+    patch_dir = root / 'output' / '3d_patches' / desc
+    patch_dir.mkdir(parents=True, exist_ok=True)
+
+    for box in boxes:
+        obj = box['info']
+        sl = box['slice']
+        patch = proj(stack[sl])
+        patch_fname = f'{prefix}-la{obj.label:04d}-zi{obj.zi:04d}'
+
+        if rescale_clip is not None:
+            patch = rescale(patch, rescale_clip)
+
+        if pad_to:
+            patch = pad_3d(patch, pad_to)
+
+        imwrite(
+            patch_dir / (patch_fname + '.tif'),
+            patch,
+            imagej=True
+        )
+    print(f'Successfully wrote {len(boxes)} patches to:\n{patch_dir}')
\ No newline at end of file
diff --git a/extensions/chaeo/test_zstack.py b/extensions/chaeo/test_zstack.py
new file mode 100644
index 00000000..1f9fcf96
--- /dev/null
+++ b/extensions/chaeo/test_zstack.py
@@ -0,0 +1,31 @@
+import unittest
+
+from extensions.chaeo.zstack import build_stack_mask
+from model_server.accessors import InMemoryDataAccessor
+
+class TestZStackDerivedDataProducts(unittest.TestCase):
+
+    def setUp(self) -> None:
+        # need test data incl obj map
+        self.obmap = None
+        self.stack = None
+
+    def test_zmask_makes_correct_boxes(self):
+        zmask, meta = build_stack_mask(
+            'test_zmask_with boxes',
+            self.stack,
+            self.obmap,
+            mask_type='boxes',
+        )
+        zmask_acc = InMemoryDataAccessor(zmask)
+        self.assertTrue(zmask_acc.is_object_map())
+
+        # assert dimensionality of zmask
+        self.assertEqual(zmask.shape_dict['Z'] > 1)
+        self.assertEqual(zmask.shape_dict['C'] == 1)
+
+        # assert non-trivial meta info in boxes
+        pass
+
+    def test_zmask_makes_correct_contours(self):
+        pass
\ No newline at end of file
diff --git a/extensions/chaeo/zstack.py b/extensions/chaeo/zstack.py
new file mode 100644
index 00000000..475f6fd8
--- /dev/null
+++ b/extensions/chaeo/zstack.py
@@ -0,0 +1,109 @@
+import numpy as np
+import pandas as pd
+
+from skimage.measure import find_contours, regionprops_table
+
+# build a single boolean 3d mask (objects v. bboxes) and return bounding boxes
+def build_stack_mask(desc, obmap, stack, filters=None, mask_type='contour', expand_box_by=(0, 0)): # TODO: specify boxes data type
+    """
+
+    filters: dict of (min, max) tuples
+    expand_box_by: (xy, z) pixelsf
+    """
+
+    # validate inputs
+    assert len(stack.shape) == 3, stack.shape
+    assert mask_type in ('contour', 'box'), mask_type # TODO: replace with call to validator
+
+    for k in filters.keys():
+        assert k in ('area', 'solidity')
+        vmin, vmax = filters[k]
+        assert vmin >= 0
+
+    # build object query
+    query_str = 'label > 0'  # always true
+    if filters:
+        for k in filters.keys():
+            assert k in ('area', 'solidity')
+            vmin, vmax = filters[k]
+            assert vmin >= 0
+            query_str = query_str + f' & {k} > {vmin} & {k} < {vmax}'
+
+    # build dataframe of objects, assign z index to each object
+    argmax = stack.argmax(axis=0)
+    df = (
+        pd.DataFrame(
+            regionprops_table(
+                obmap,
+                intensity_image=argmax,
+                properties=('label', 'area', 'intensity_mean', 'solidity', 'bbox')
+            )
+        )
+        .query(query_str)
+        .rename(
+            columns={
+                'bbox-0': 'y0',
+                'bbox-1': 'x0',
+                'bbox-2': 'y1',
+                'bbox-3': 'x1',
+            }
+        )
+    )
+    df['zi'] = df['intensity_mean'].round().astype('int')
+
+    # make an object map where label is replaced by focus position in stack and background is -1
+    lut = np.zeros(obmap.max() + 1) - 1
+    lut[df.label] = df.zi
+
+    # convert bounding boxes to slices
+    ebxy, ebz = expand_box_by
+    nz, h, w = stack.shape
+
+    boxes = []
+    for ob in df.itertuples(name='LabeledObject'):
+        y0 = max(ob.y0 - ebxy, 0)
+        y1 = min(ob.y1 + ebxy, h - 1)
+        x0 = max(ob.x0 - ebxy, 0)
+        x1 = min(ob.x1 + ebxy, w - 1)
+        z0 = max(ob.zi - ebz, 0)
+        z1 = min(ob.zi + ebz, nz)
+
+        # relative bounding box positions
+        rbb = {
+            'y0': ob.y0 - y0,
+            'y1': ob.y1 - y0,
+            'x0': ob.x0 - x0,
+            'x1': ob.x1 - x0,
+        }
+
+        sl = np.s_[z0: z1 + 1, y0: y1, x0: x1]
+
+        # compute contours
+        obmask = (obmap == ob.label)
+        contour = find_contours(obmask)
+        mask = obmask[ob.y0: ob.y1, ob.x0: ob.x1]
+
+        boxes.append({
+            'info': ob,
+            'slice': sl,
+            'relative_bounding_box': rbb,
+            'contour': contour,
+            'mask': mask
+        })
+
+    # build mask z-stack
+    zi_st = np.zeros(stack.shape, dtype='bool')
+    if mask_type == 'contour':
+        zi_map = (lut[obmap] + 1.0).astype('int')
+        idxs = np.array([zi_map]) - 1
+        np.put_along_axis(zi_st, idxs, 1, axis=0)
+
+        # change background level from to 0 in final frame
+        zi_st[-1, :, :][obmap == 0] = 0
+
+    elif mask_type == 'box':
+        for bb in boxes:
+            sl = bb['slice']
+            zi_st[sl] = 1
+
+    return zi_st, boxes
\ No newline at end of file
diff --git a/extensions/ilastik/examples/__init__.py b/extensions/ilastik/examples/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/extensions/examples/ilastik3d.py b/extensions/ilastik/examples/ilastik3d.py
similarity index 100%
rename from extensions/examples/ilastik3d.py
rename to extensions/ilastik/examples/ilastik3d.py
diff --git a/extensions/ilastik/validators.py b/extensions/ilastik/validators.py
new file mode 100644
index 00000000..d82e24a8
--- /dev/null
+++ b/extensions/ilastik/validators.py
@@ -0,0 +1,5 @@
+from model_server.accessors import GenericImageDataAccessor
+
+def is_object_map(img: GenericImageDataAccessor):
+    # TODO: implement
+    pass
\ No newline at end of file
diff --git a/model_server/batch.py b/model_server/batch.py
new file mode 100644
index 00000000..999aa4cc
--- /dev/null
+++ b/model_server/batch.py
@@ -0,0 +1,22 @@
+def get_tif_seq(where, prefix=None, verbose=True):
+    files = [ff for ff in os.listdir(where) if ff.startswith(prefix)]
+    files.sort()
+    paths = [where / fn for fn in files]
+    ndas = [imread(pa.__str__()) for pa in paths]
+    assert all([n.shape == ndas[0].shape and n.dtype == ndas[0].dtype for n in ndas])
+    if verbose:
+        print(f'Successfully read {len(ndas)} x {ndas[0].shape} px images of type {ndas[0].dtype} from:\n{where}')
+    return ndas
+
+def write_tif_zstack(data, desc, dtype='uint16', subdir='output'):
+    where = root / subdir
+    filename = desc + '.tif'
+    if not os.path.exists(where):
+        os.makedirs(where)
+    abspath = where / filename
+    imwrite(
+        abspath,
+        np.array(data).astype(dtype),
+        imagej=True
+    )
+    print(f'Successfully wrote {len(data)} x {data[0].shape} px images of type {dtype} to:\n{abspath}')
\ No newline at end of file
diff --git a/model_server/process.py b/model_server/process.py
new file mode 100644
index 00000000..26fd3005
--- /dev/null
+++ b/model_server/process.py
@@ -0,0 +1,52 @@
+"""
+Image processing utility functions
+"""
+from math import ceil, floor
+
+import numpy as np
+from skimage.exposure import rescale_intensity
+
+
+def pad(im, mpx):  # now in model_server.batch
+    '''Pads and crops image width edge values to specified dimension'''
+    dh = 0.5 * (mpx - im.shape[0])
+    dw = 0.5 * (mpx - im.shape[1])
+
+    if dw < 0:
+        x0 = floor(-dw)
+        x1 = x0 + mpx
+        im = im[:, x0:x1]
+        dw = 0
+    if dh < 0:
+        y0 = floor(-dh)
+        y1 = y0 + mpx
+        im = im[y0:y1, :]
+        dh = 0
+
+    border = ((floor(dh), ceil(dh)), (floor(dw), ceil(dw)))
+    padded = np.pad(im, border, mode='constant')
+    if padded.shape != (mpx, mpx):
+        raise Exception(f'Incorrect image shape: {padded.shape} v. {(mpx, mpx)}')
+    return padded
+
+def pad_3d(im, mpx): # im: [z x h x w]
+    assert(len(im.shape) == 3)
+    nz, h, w = im.shape
+    padded = np.zeros((nz, mpx, mpx), dtype=im.dtype)
+    for zi in range(nz):
+        padded[zi, :, :] = pad(im[zi, :, :], mpx)
+    return padded
+
+def resample(nda, cmin=0, cmax=2**16): # now in model_server.batch
+    return rescale_intensity(
+        np.clip(nda, cmin, cmax),
+        in_range=(cmin, cmax + 1),
+        out_range=(0, 2**8)
+    ).astype('uint8')
+
+
+def rescale(nda, clip=0.0): # now in model_server.batch
+    clip_pct = (100.0 * clip, 100.0 * (1.0 - clip))
+    cmin, cmax = np.percentile(nda, clip_pct)
+    rescaled = rescale_intensity(nda, in_range=(cmin, cmax))
+    return rescaled
\ No newline at end of file
diff --git a/readme.md b/readme.md
new file mode 100644
index 00000000..c424e968
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,17 @@
+# model_server
+
+# How to extend service
+Add sub-package to extensions
+Add models that inherit from model_server.Model 
+In workflows, implement pipelines with File I/O via accessors.GenericImageDataAccessor
+(to decouple model logic from image data source)
+Set extensions-specific folders, etc. in conf relative to overall package root (set by user)
+As much as possible, set pipeline and model parameters with defaults and support overrides by optional API arguments; 
+this helps non-coding users control their jobs
+Set up API endpoints in router, following as much as possible existing conventions with load, infer, etc. keyword
+
+decouple data access from processing
+
+control either via batch runners or API (serial)
+
+workflow: combines data access with processing via models, produces primary outputs
\ No newline at end of file
-- 
GitLab