#!/usr/bin/env python
# -*- coding: UTF-8 -*-

"""
Image processing pipelines for phenotyping projects.
"""
from collections import Counter
from datetime import date
import json
from math import cos, pi, sin
import os.path as op
import string
import sys
from typing import Any, List, Optional, Tuple

import numpy as np

from ..apps.base import setup_magick_home

# Attempt to set MACICK_HOME ENV variable if imagemagick installed with homebrew on Mac
setup_magick_home()

from PIL.Image import open as iopen
from pyefd import elliptic_fourier_descriptors
from pytesseract import image_to_string
from scipy.ndimage import binary_fill_holes, distance_transform_edt
from scipy.optimize import fmin_bfgs as fmin
from skimage.color import gray2rgb, rgb2gray
from skimage.feature import canny, peak_local_max
from skimage.filters import roberts, sobel, threshold_otsu
from skimage.measure import find_contours, label, regionprops
from skimage.morphology import closing, disk
from skimage.segmentation import clear_border, watershed
from wand.image import Image
from webcolors import normalize_integer_triplet, rgb_to_hex

from ..algorithms.formula import get_kmeans, reject_outliers
from ..apps.base import (
    ActionDispatcher,
    OptionParser,
    datadir,
    iglob,
    logger,
    mkdir,
)
from ..formats.base import must_open
from ..formats.pdf import cat
from ..utils.webcolors import closest_color
from .base import (
    Rectangle,
    latex,
    load_image,
    normalize_axes,
    plt,
    savefig,
    set_helvetica_axis,
)

np.seterr(all="ignore")

RGBTuple = Tuple[int, int, int]


class Seed(object):
    """
    Seed object with metrics.
    """

    def __init__(
        self,
        imagename: str,
        accession: str,
        seedno: int,
        rgb: RGBTuple,
        props: Any,
        efds: np.ndarray,
        exif: dict,
    ):
        self.imagename = imagename
        self.accession = accession
        self.seedno = seedno
        y, x = props.centroid
        self.x, self.y = int(round(x)), int(round(y))
        self.location = f"{self.x}|{self.y}"
        self.area = int(round(props.area))
        self.length = int(round(props.major_axis_length))
        self.width = int(round(props.minor_axis_length))
        self.props = props
        self.efds = efds
        self.circularity = 4 * pi * props.area / props.perimeter**2
        self.rgb = rgb
        self.colorname = closest_color(rgb)
        self.datetime = exif.get("exif:DateTimeOriginal", date.today())
        self.rgbtag = triplet_to_rgb(rgb)
        self.pixeltag = f"length={self.length} width={self.width} area={self.area}"
        self.hashtag = " ".join((self.rgbtag, self.colorname))
        self.calibrated = False

    def __str__(self):
        fields = [
            self.imagename,
            self.datetime,
            self.accession,
            self.seedno,
            self.location,
            self.area,
            f"{self.circularity:.2f}",
            self.length,
            self.width,
            self.colorname,
            self.rgbtag,
        ]
        if self.calibrated:
            fields += [
                self.pixelcmratio,
                self.rgbtransform,
                self.correctedlength,
                self.correctedwidth,
                self.correctedcolorname,
                self.correctedrgb,
            ]
        fields += [",".join(f"{x:.3f}" for x in self.efds)]
        return "\t".join(str(x) for x in fields)

    @classmethod
    def header(cls, calibrated: bool = False) -> str:
        """
        Return header line for the TSV file.
        """
        fields = (
            "ImageName DateTime Accession SeedNum Location "
            "Area Circularity Length(px) Width(px) ColorName RGB".split()
        )
        if calibrated:
            fields += (
                "PixelCMratio RGBtransform Length(cm)"
                " Width(cm) CorrectedColorName CorrectedRGB".split()
            )
        fields += ["EllipticFourierDescriptors"]
        return "\t".join(fields)

    def calibrate(self, pixel_cm_ratio: float, tr: np.ndarray):
        """
        Calibrate pixel-inch ratio and color adjustment.
        """
        self.pixelcmratio = f"{pixel_cm_ratio:.2f}"
        self.rgbtransform = ",".join([f"{x:.2f}" for x in tr.flatten()])
        self.correctedlength = f"{self.length / pixel_cm_ratio:.2f}"
        self.correctedwidth = f"{self.width / pixel_cm_ratio:.2f}"
        correctedrgb = np.dot(tr, np.array(self.rgb))
        self.correctedrgb = triplet_to_rgb(correctedrgb)
        self.correctedcolorname = closest_color(correctedrgb)
        self.calibrated = True


def sam(img: np.ndarray, checkpoint: str) -> List[dict]:
    """
    Use Segment Anything Model (SAM) to segment objects.
    """
    try:
        from segment_anything import SamAutomaticMaskGenerator, sam_model_registry
    except ImportError:
        logger.fatal("segment_anything not installed. Please install it first.")
        sys.exit(1)

    model_type = "vit_h"
    if not op.exists(checkpoint):
        raise AssertionError(
            f"File `{checkpoint}` not found, please specify --sam-checkpoint"
        )
    sam = sam_model_registry[model_type](checkpoint=checkpoint)
    logger.info("Using SAM model `%s` (%s)", model_type, checkpoint)
    mask_generator = SamAutomaticMaskGenerator(sam)
    return mask_generator.generate(img)


def is_overlapping(mask1: dict, mask2: dict, threshold=0.5):
    """
    Check if bounding boxes of mask1 and mask2 overlap more than the given
    threshold.
    """
    x1, y1, w1, h1 = mask1["bbox"]
    x2, y2, w2, h2 = mask2["bbox"]
    x_overlap = max(0, min(x1 + w1, x2 + w2) - max(x1, x2))
    y_overlap = max(0, min(y1 + h1, y2 + h2) - max(y1, y2))
    intersection = x_overlap * y_overlap
    return intersection / min(w1 * h1, w2 * h2) > threshold


def deduplicate_masks(masks: List[dict], threshold=0.5):
    """
    Deduplicate masks to retain only the foreground objects.
    """
    masks_sorted = sorted(masks, key=lambda x: x["area"])
    retained_masks = []

    for mask in masks_sorted:
        if not any(
            is_overlapping(mask, retained_mask, threshold)
            for retained_mask in retained_masks
        ):
            retained_masks.append(mask)
    return retained_masks


def rgb_to_triplet(rgb: str) -> RGBTuple:
    """
    Convert RGB string to triplet.
    """
    return tuple([int(x) for x in rgb.split(",")][:3])


def triplet_to_rgb(triplet: RGBTuple) -> str:
    """
    Convert triplet to RGB string.
    """
    triplet = normalize_integer_triplet(triplet)
    return ",".join(str(int(round(x))) for x in triplet)


def main():

    actions = (
        ("batchseeds", "extract seed metrics for each image in a directory"),
        ("seeds", "extract seed metrics from one image"),
        ("calibrate", "calibrate pixel-inch ratio and color adjustment"),
    )
    p = ActionDispatcher(actions)
    p.dispatch(globals())


def total_error(x: np.ndarray, colormap: Tuple[Tuple[np.ndarray, np.ndarray]]) -> float:
    """
    Calculate total error between observed and expected colors.
    """
    xs = np.reshape(x, (3, 3))
    error_squared = sum(np.linalg.norm(np.dot(xs, o) - e) ** 2 for o, e in colormap)
    return error_squared**0.5


def calibrate(args):
    """
    %prog calibrate calibrate.JPG boxsize

    Calibrate pixel-inch ratio and color adjustment.
    - `calibrate.JPG` is the photo containig a colorchecker
    - `boxsize` is the measured size for the boxes on printed colorchecker, in
      squared centimeter (cm2) units
    """
    xargs = args[2:]
    p = OptionParser(calibrate.__doc__)
    _, args, _ = add_seeds_options(p, args)

    if len(args) != 2:
        sys.exit(not p.print_help())

    imagefile, boxsize = args
    boxsize = float(boxsize)

    # Read in color checker
    colorcheckerfile = op.join(datadir, "colorchecker.txt")
    colorchecker = []
    expected = 0
    with open(colorcheckerfile, encoding="utf-8") as file:
        for row in file:
            boxes = row.split()
            colorchecker.append(boxes)
            expected += len(boxes)

    folder = op.split(imagefile)[0]
    objects = seeds([imagefile, f"--outdir={folder}"] + xargs)
    nseeds = len(objects)
    logger.debug("Found %d boxes (expected=%d)", nseeds, expected)
    assert (
        expected - 4 <= nseeds <= expected + 4
    ), f"Number of boxes drastically different from {expected}"

    # Calculate pixel-cm ratio
    boxes = [t.area for t in objects]
    reject = reject_outliers(boxes)
    retained_boxes = [b for r, b in zip(reject, boxes) if not r]
    mbox = np.median(retained_boxes)  # in pixels
    pixel_cm_ratio = (mbox / boxsize) ** 0.5
    logger.debug("Median box size: %d pixels. Measured box size: %d cm2", mbox, boxsize)
    logger.debug("Pixel-cm ratio: %.2f", pixel_cm_ratio)

    xs = [t.x for t in objects]
    ys = [t.y for t in objects]
    xs = [float(itemx) for itemx in xs]
    ys = [float(itemy) for itemy in ys]
    idx_xs = get_kmeans(xs, 6)
    idx_ys = get_kmeans(ys, 4)
    for xi, yi, s in zip(idx_xs, idx_ys, objects):
        s.rank = (yi, xi)

    objects.sort(key=lambda x: x.rank)

    colormap = []
    for s in objects:
        x, y = s.rank
        observed, expected = s.rgb, rgb_to_triplet(colorchecker[x][y])
        colormap.append((np.array(observed), np.array(expected)))

    # Color transfer
    tr0 = np.eye(3).flatten()
    print("Initial distance:", total_error(tr0, colormap), file=sys.stderr)
    tr = fmin(total_error, tr0, args=(colormap,))
    tr.resize((3, 3))
    print("RGB linear transform:\n", tr, file=sys.stderr)
    calib = {"PixelCMratio": pixel_cm_ratio, "RGBtransform": tr.tolist()}

    jsonfile = op.join(folder, "calibrate.json")
    fw = must_open(jsonfile, "w")
    print(json.dumps(calib, indent=4), file=fw)
    fw.close()
    logger.debug("Calibration specs written to `%s`.", jsonfile)

    return jsonfile


def add_seeds_options(p, args):
    """
    Add options to the OptionParser for seeds() and batchseeds() functions.
    """
    g1 = p.add_argument_group("Image manipulation")
    g1.add_argument("--rotate", default=0, type=int, help="Rotate degrees clockwise")
    g1.add_argument(
        "--rows", default=":", help="Crop rows e.g. `:800` from first 800 rows"
    )
    g1.add_argument(
        "--cols", default=":", help="Crop cols e.g. `-800:` from last 800 cols"
    )
    g1.add_argument("--labelrows", help="Label rows e.g. `:800` from first 800 rows")
    g1.add_argument("--labelcols", help="Label cols e.g. `-800: from last 800 rows")
    valid_colors = ("red", "green", "blue", "purple", "yellow", "orange", "INVERSE")
    g1.add_argument(
        "--changeBackground",
        default=0,
        choices=valid_colors,
        help="Changes background color",
    )

    g2 = p.add_argument_group("Object recognition")
    g2.add_argument(
        "--minsize",
        default=0.2,
        type=float,
        help="Min percentage of object to image",
    )
    g2.add_argument(
        "--maxsize", default=20, type=float, help="Max percentage of object to image"
    )
    g2.add_argument(
        "--count", default=100, type=int, help="Report max number of objects"
    )
    g2.add_argument(
        "--watershed",
        default=False,
        action="store_true",
        help="Run watershed to segment touching objects",
    )

    g3 = p.add_argument_group("De-noise")
    valid_filters = ("canny", "otsu", "roberts", "sam", "sobel")
    g3.add_argument(
        "--filter",
        default="canny",
        choices=valid_filters,
        help="Edge detection algorithm",
    )
    g3.add_argument(
        "--sigma",
        default=1,
        type=int,
        help="Canny edge detection sigma, higher for noisy image",
    )
    g3.add_argument(
        "--kernel",
        default=2,
        type=int,
        help="Edge closure, higher if the object edges are dull",
    )
    g3.add_argument(
        "--border", default=5, type=int, help="Remove image border of certain pixels"
    )
    g3.add_argument(
        "--sam-checkpoint", default="sam_vit_h_4b8939.pth", help="SAM checkpoint file"
    )

    g4 = p.add_argument_group("Output")
    g4.add_argument("--calibrate", help="JSON file to correct distance and color")
    g4.add_argument(
        "--edges",
        default=False,
        action="store_true",
        help="Visualize edges in middle PDF panel",
    )
    g4.add_argument(
        "--outdir", default=".", help="Store intermediate images and PDF in folder"
    )
    g4.add_argument("--prefix", help="Output prefix")
    g4.add_argument(
        "--noheader", default=False, action="store_true", help="Do not print header"
    )
    opts, args, iopts = p.set_image_options(args, figsize="12x6", style="white")

    return opts, args, iopts


def batchseeds(args):
    """
    %prog batchseeds folder

    Extract seed metrics for each image in a directory.
    """
    xargs = args[1:]
    p = OptionParser(batchseeds.__doc__)
    opts, args, _ = add_seeds_options(p, args)

    if len(args) != 1:
        sys.exit(not p.print_help())

    (folder,) = args
    folder = folder.rstrip("/")
    outdir = folder + "-debug"
    outfile = folder + "-output.tsv"
    assert op.isdir(folder)
    images = []
    jsonfile = opts.calibrate or op.join(folder, "calibrate.json")
    if not op.exists(jsonfile):
        jsonfile = None
    for im in iglob(folder, "*.jpg,*.JPG,*.png"):
        if im.endswith((".resize.jpg", ".main.jpg", ".label.jpg")):
            continue
        if op.basename(im).startswith("calibrate"):
            continue
        images.append(im)

    fw = must_open(outfile, "w")
    print(Seed.header(calibrated=bool(jsonfile)), file=fw)
    nseeds = 0
    for im in images:
        imargs = [im, "--noheader", f"--outdir={outdir}"] + xargs
        if jsonfile:
            imargs += [f"--calibrate={jsonfile}"]
        objects = seeds(imargs)
        for o in objects:
            print(o, file=fw)
        nseeds += len(objects)
    fw.close()
    logger.debug("Processed %d images.", len(images))
    logger.debug("A total of %d objects written to `%s`.", nseeds, outfile)

    pdfs = iglob(outdir, "*.pdf")
    outpdf = folder + "-output.pdf"
    cat(pdfs + [f"--outfile={outpdf}"])

    logger.debug("Debugging information written to `%s`.", outpdf)
    return outfile


def p_round(n: int, precision: int = 5) -> int:
    """
    Round to the nearest precision.
    """
    precision = int(precision)
    return int(round(n / float(precision))) * precision


def pixel_stats(img: List[RGBTuple]) -> RGBTuple:
    """
    Get the most common pixel color.
    """
    img = [(p_round(r), p_round(g), p_round(b)) for r, g, b in img]
    c = Counter(img)
    imgx, _ = c.most_common(1)[0]
    return imgx


def slice_to_ints(s: str, m: int) -> Tuple[int, int]:
    """
    Parse slice string.
    """
    assert ":" in s
    ra, rb = s.split(":")
    ra = 0 if ra == "" else int(ra)
    rb = m if rb == "" else int(rb)
    return ra, rb


def convert_background(pngfile: str, new_background: str):
    """
    Replace the background color with the specified background color, default is
    blue.
    """
    if new_background:
        _name, _ext = op.splitext(op.basename(pngfile))
        _name += "_bgxform"
        newfile = op.join(op.dirname(pngfile), _name + _ext)

        img = iopen(pngfile)
        pixels = list(img.getdata())

        # Get Standard Deviation of RGB
        rgb_array = []
        for x in range(255):
            rgb_array.append(x)
        std_rgb = np.std(rgb_array) * 0.8

        # Get average color
        obcolor = [0, 0, 0]
        pixel_values = []
        for t in range(3):
            pixel_color = img.getdata(band=t)
            for pixel in pixel_color:
                if pixel > std_rgb:
                    pixel_values.append(pixel)
            obcolor[t] = sum(pixel_values) // len(pixel_values)

        # Get background color using average color and standard deviation
        for t in range(3):
            pixel_color = img.getdata(band=t)
            seed_pixel_values = []
            for i in pixel_color:
                if obcolor[t] - std_rgb < i < obcolor[t] + std_rgb:
                    seed_pixel_values.append(i)
            obcolor[t] = sum(seed_pixel_values) // len(seed_pixel_values)
        # Selection of colors based on option parser
        nbcolor = [0, 0, 0]
        if new_background == "INVERSE":
            for t in range(3):
                nbcolor[t] = 255 - obcolor[t]
        elif new_background == "red":
            nbcolor = [255, 0, 0]

        elif new_background == "green":
            nbcolor = [0, 255, 0]

        elif new_background == "blue":
            nbcolor = [0, 0, 255]

        elif new_background == "yellow":
            nbcolor = [255, 255, 0]

        elif new_background == "purple":
            nbcolor = [255, 0, 255]

        elif new_background == "orange":
            nbcolor = [255, 165, 0]

        # Change Background Color
        obcolor = tuple(obcolor)
        nbcolor = tuple(nbcolor)
        for idx, pixel in enumerate(pixels):
            if all(o - std_rgb <= p <= o + std_rgb for o, p in zip(obcolor, pixel)):
                pixels[idx] = nbcolor
        img.putdata(pixels)
        img.save(newfile, "PNG")
        return newfile
    return pngfile


def convert_image(
    pngfile: str,
    pf: str,
    outdir: str = ".",
    resize: int = 1000,
    img_format: str = "jpeg",
    rotate: int = 0,
    rows: str = ":",
    cols: str = ":",
    labelrows: Optional[str] = None,
    labelcols: Optional[str] = None,
) -> Tuple[str, str, Optional[str], dict]:
    """
    Convert image to JPEG format and resize it.
    """
    resizefile = op.join(outdir, pf + ".resize.jpg")
    mainfile = op.join(outdir, pf + ".main.jpg")
    labelfile = op.join(outdir, pf + ".label.jpg")
    img = Image(filename=pngfile)
    exif = dict((k, img.metadata[k]) for k in img.metadata if k.startswith("exif:"))

    # Rotation, slicing and cropping of main image
    if rotate:
        img.rotate(rotate)
    if resize:
        w, h = img.size
        if min(w, h) > resize:
            if w < h:
                nw, nh = resize, resize * h // w
            else:
                nw, nh = resize * w // h, resize
            img.resize(nw, nh)
            logger.debug(
                "Image `%s` resized from %dpx:%dpx to %dpx:%dpx", pngfile, w, h, nw, nh
            )
    img.format = img_format
    img.save(filename=resizefile)

    rimg = img.clone()
    if rows != ":" or cols != ":":
        w, h = img.size
        ra, rb = slice_to_ints(rows, h)
        ca, cb = slice_to_ints(cols, w)
        # left, top, right, bottom
        logger.debug("Crop image to %d:%d %d:%d", ra, rb, ca, cb)
        img.crop(ca, ra, cb, rb)
        img.format = img_format
        img.save(filename=mainfile)
    else:
        mainfile = resizefile

    # Extract text labels from image
    if labelrows or labelcols:
        w, h = rimg.size
        if labelrows and not labelcols:
            labelcols = ":"
        if labelcols and not labelrows:
            labelrows = ":"
        ra, rb = slice_to_ints(labelrows, h)
        ca, cb = slice_to_ints(labelcols, w)
        logger.debug("Extract label from %d:%d %d:%d", ra, rb, ca, cb)
        rimg.crop(ca, ra, cb, rb)
        rimg.format = img_format
        rimg.save(filename=labelfile)
    else:
        labelfile = None

    return resizefile, mainfile, labelfile, exif


def extract_label(labelfile: str) -> str:
    """
    Extract accession number from label image.
    """
    accession = image_to_string(iopen(labelfile))
    accession = " ".join(accession.split())  # normalize spaces
    accession = "".join(x for x in accession if x in string.printable)
    if not accession:
        accession = "none"
    return accession


def efd_feature(contour: np.ndarray) -> np.ndarray:
    """
    To use EFD as features, one can write a small wrapper function.

    Based on: https://pyefd.readthedocs.io/en/latest
    """
    coeffs = elliptic_fourier_descriptors(contour, normalize=True)
    # skip the first three coefficients, which are always 1, 0, 0
    return coeffs.flatten()[3:]


def seeds(args):
    """
    %prog seeds [pngfile|jpgfile]

    Extract seed metrics from [pngfile|jpgfile]. Use --rows and --cols to crop image.
    """
    p = OptionParser(seeds.__doc__)
    p.set_outfile()
    opts, args, iopts = add_seeds_options(p, args)

    if len(args) != 1:
        sys.exit(not p.print_help())

    (pngfile,) = args
    pf = opts.prefix or op.basename(pngfile).rsplit(".", 1)[0]
    sigma, kernel = opts.sigma, opts.kernel
    rows, cols = opts.rows, opts.cols
    labelrows, labelcols = opts.labelrows, opts.labelcols
    ff = opts.filter
    calib = opts.calibrate
    outdir = opts.outdir
    if outdir and outdir != ".":
        mkdir(outdir)
    if calib:
        calib = json.load(must_open(calib))
        pixel_cm_ratio, tr = calib["PixelCMratio"], calib["RGBtransform"]
        tr = np.array(tr)
    nbcolor = opts.changeBackground
    pngfile = convert_background(pngfile, nbcolor)
    resizefile, mainfile, labelfile, exif = convert_image(
        pngfile,
        pf,
        outdir=outdir,
        rotate=opts.rotate,
        rows=rows,
        cols=cols,
        labelrows=labelrows,
        labelcols=labelcols,
    )
    oimg = load_image(resizefile)
    img = load_image(mainfile)

    _, (ax1, ax2, ax3, ax4) = plt.subplots(ncols=4, nrows=1, figsize=(iopts.w, iopts.h))
    # Edge detection
    img_gray = rgb2gray(img)
    w, h = img_gray.shape
    canvas_size = w * h
    min_size = int(round(canvas_size * opts.minsize / 100))
    max_size = int(round(canvas_size * opts.maxsize / 100))

    logger.debug("Running %s edge detection …", ff)
    if ff == "canny":
        edges = canny(img_gray, sigma=opts.sigma)
    elif ff == "otsu":
        thresh = threshold_otsu(img_gray)
        edges = img_gray > thresh
    elif ff == "roberts":
        edges = roberts(img_gray)
    elif ff == "sobel":
        edges = sobel(img_gray)
    if ff == "sam":
        masks = sam(img, opts.sam_checkpoint)
        filtered_masks = [
            mask for mask in masks if min_size <= mask["area"] <= max_size
        ]
        deduplicated_masks = deduplicate_masks(filtered_masks)
        logger.info(
            "SAM: %d (raw) → %d (size filtered) → %d (deduplicated)",
            len(masks),
            len(filtered_masks),
            len(deduplicated_masks),
        )
        labels = np.zeros(img_gray.shape, dtype=int)
        for i, mask in enumerate(deduplicated_masks):
            labels[mask["segmentation"]] = i + 1
        labels = clear_border(labels)
    else:
        edges = clear_border(edges, buffer_size=opts.border)
        selem = disk(kernel)
        closed = closing(edges, selem) if kernel else edges
        filled = binary_fill_holes(closed)

        # Watershed algorithm
        if opts.watershed:
            distance = distance_transform_edt(filled)
            local_maxi = peak_local_max(distance, threshold_rel=0.05, indices=False)
            coordinates = peak_local_max(distance, threshold_rel=0.05)
            markers, nmarkers = label(local_maxi, return_num=True)
            logger.debug("Identified %d watershed markers", nmarkers)
            labels = watershed(closed, markers, mask=filled)
        else:
            labels = label(filled)

    # Object size filtering
    logger.debug(
        "Find objects with pixels between %d (%.2f%%) and %d (%d%%)",
        min_size,
        opts.minsize,
        max_size,
        opts.maxsize,
    )

    # Plotting
    ax1.set_title("Original picture")
    ax1.imshow(oimg)

    params = rf"{ff}, $\sigma$={sigma}, $k$={kernel}"
    if opts.watershed:
        params += ", watershed"
    ax2.set_title(f"Edge detection\n({params})")
    if ff != "sam":
        closed = gray2rgb(closed)
    ax2_img = labels
    if opts.edges:
        ax2_img = closed
    elif opts.watershed:
        ax2.plot(coordinates[:, 1], coordinates[:, 0], "g.")
    ax2.imshow(ax2_img, cmap=iopts.cmap)

    ax3.set_title("Object detection")
    ax3.imshow(img)

    filename = op.basename(pngfile)
    if labelfile:
        accession = extract_label(labelfile)
    else:
        accession = pf

    # Calculate region properties
    rp = regionprops(labels)
    rp = [x for x in rp if min_size <= x.area <= max_size]
    rp.sort(key=lambda x: x.area, reverse=True)
    nb_labels = len(rp)
    logger.debug("A total of %d objects identified.", nb_labels)
    objects = []
    for i, props in enumerate(rp):
        i += 1
        if i > opts.count:
            break

        contour = find_contours(labels == props.label, 0.5)[0]
        efds = efd_feature(contour)
        y0, x0 = props.centroid
        orientation = props.orientation
        major, minor = props.major_axis_length, props.minor_axis_length
        major_dx = sin(orientation) * major / 2
        major_dy = cos(orientation) * major / 2
        minor_dx = cos(orientation) * minor / 2
        minor_dy = -sin(orientation) * minor / 2
        ax2.plot((x0 - major_dx, x0 + major_dx), (y0 - major_dy, y0 + major_dy), "r-")
        ax2.plot((x0 - minor_dx, x0 + minor_dx), (y0 - minor_dy, y0 + minor_dy), "r-")
        ax2.plot(contour[:, 1], contour[:, 0], "y-")

        npixels = int(props.area)
        # Sample the center of the blob for color
        d = min(int(round(minor / 2 * 0.35)) + 1, 50)
        x0d, y0d = int(round(x0)), int(round(y0))
        square = img[(y0d - d) : (y0d + d), (x0d - d) : (x0d + d)]
        pixels = []
        for row in square:
            pixels.extend(row)
        logger.debug(
            "Seed #%d: %d pixels (%d sampled) - %.2f%%",
            i,
            npixels,
            len(pixels),
            100.0 * npixels / canvas_size,
        )

        rgb = pixel_stats(pixels)
        objects.append(Seed(filename, accession, i, rgb, props, efds, exif))
        minr, minc, maxr, maxc = props.bbox
        rect = Rectangle(
            (minc, minr), maxc - minc, maxr - minr, fill=False, ec="w", lw=1
        )
        ax3.add_patch(rect)
        mc, mr = (minc + maxc) // 2, (minr + maxr) // 2
        ax3.text(mc, mr, f"{i}", color="w", ha="center", va="center", size=6)

    for ax in (ax2, ax3):
        ax.set_xlim(0, h)
        ax.set_ylim(w, 0)

    # Output identified seed stats
    ax4.text(0.1, 0.92, f"File: {latex(filename)}", color="g")
    ax4.text(0.1, 0.86, f"Label: {latex(accession)}", color="m")
    yy = 0.8
    fw = must_open(opts.outfile, "w")
    if not opts.noheader:
        print(Seed.header(calibrated=calib), file=fw)
    for o in objects:
        if calib:
            o.calibrate(pixel_cm_ratio, tr)
        print(o, file=fw)
        i = o.seedno
        if i > 7:
            continue
        ax4.text(0.01, yy, str(i), va="center", bbox=dict(fc="none", ec="k"))
        ax4.text(0.1, yy, o.pixeltag, va="center")
        yy -= 0.04
        ax4.add_patch(
            Rectangle((0.1, yy - 0.025), 0.12, 0.05, lw=0, fc=rgb_to_hex(o.rgb))
        )
        ax4.text(0.27, yy, o.hashtag, va="center")
        yy -= 0.06
    ax4.text(
        0.1,
        yy,
        f"(A total of {nb_labels} objects displayed)",
        color="darkslategray",
    )
    normalize_axes(ax4)

    for ax in (ax1, ax2, ax3):
        set_helvetica_axis(ax)

    image_name = op.join(outdir, pf + "." + iopts.format)
    savefig(image_name, dpi=iopts.dpi, iopts=iopts)
    return objects


if __name__ == "__main__":
    main()
