← Back to list

Measuring Flatfish Without Neural Networks

A deterministic OpenCV pipeline for real-time morphometric measurement of flatfish in aquaculture.

Ph.D. Javier Osuna in Towards Data Engineering · 2026-06-29 12:01 · 0 claps · 12.2 min read
#computer-vision #opencv #aquaculture #industrial-ai #image-processing
Open on Medium ↗
Wiki topics: ML · Machine Learning CUL · Culture & Media ⚖️ · Law & Justice

Measuring Flatfish Without Neural Networks

A deterministic OpenCV pipeline for real-time morphometric measurement of flatfish in aquaculture.

Not every industrial computer vision system needs a neural network.

In many real production environments, the best architecture is not necessarily the most complex one. It is the one that can be inspected, maintained, deployed on modest hardware and trusted under unstable operating conditions.

This is especially true in industrial aquaculture, where images are rarely captured in laboratory-like conditions. Cameras operate close to water. Illumination changes. Reflections appear. Conveyor belts are wet. Backgrounds are not perfectly uniform. And yet, the system must keep measuring accurately, in real time, without interrupting production.

The FLATCLASS system was designed around that principle: use deterministic computer vision to measure juvenile flatfish automatically, without physical contact and without neural-network inference.

The goal is simple to state but difficult to engineer: capture an overhead image of a juvenile flatfish moving on a conveyor belt, segment the fish, extract its silhouette, compute length and width, convert pixels into millimetres and publish the result as structured industrial data.

The code snippets below are simplified but technically faithful versions of the production pipeline. They focus on the main engineering decisions without exposing deployment-specific parameters.

Why measuring juvenile flatfish matters

In aquaculture, grading is the process of grouping fish by size. It is not a cosmetic operation. It directly affects production management.

When fish of very different sizes remain in the same batch, competition for feed can increase, growth becomes less homogeneous and operational decisions become less reliable. For juvenile flatfish such as sole, size classification is especially relevant because body geometry, growth rate and handling constraints differ from those of more cylindrical fish species.

A reliable grading system needs reliable measurements. Manual sampling is slow, labour-intensive and invasive. A vision-based system, by contrast, can measure each individual specimen as it passes through a production line.

The technical problem is that hatchery images are not clean datasets. They include humidity, reflections, shadows, droplets, motion blur and non-uniform backgrounds. Any measurement pipeline must therefore be robust enough for real operating conditions, not just for benchmark images.

That is the context in which FLATCLASS was developed: an industrial computer vision pipeline for real-time morphometric measurement of juvenile flatfish.

The pipeline: from conveyor image to morphometric data

Simplified FLATCLASS pipeline: image acquisition, preprocessing, automatic segmentation, PCA-based measurement and industrial data integration

Simplified FLATCLASS pipeline: image acquisition, preprocessing, automatic segmentation, PCA-based measurement and industrial data integration

At a high level, the system follows a deterministic sequence:

Image acquisition → preprocessing → chromatic ROI detection → automatic GrabCut seed generation → segmentation → contour selection → PCA-based measurement → pixel-to-millimetre conversion → data persistence and real-time messaging.

The important point is that this is not just an image-processing script. It is an operational pipeline.

The camera captures the specimen. The preprocessing layer stabilises the image. The segmentation module extracts the fish silhouette. The measurement layer computes morphometric descriptors. The integration layer publishes the result to downstream systems.

In the deployed architecture, Node-RED orchestrates the flow, MongoDB stores the measurements and MQTT publishes real-time notifications. This turns an image into a production event: a timestamped morphometric record that can be used for grading, monitoring, traceability or decision support.

Why this is not a neural network problem

Deep learning is extremely powerful. In aquaculture, neural networks are already used for detection, classification, segmentation, biomass estimation and behaviour analysis.

But this particular problem has a different structure.

The object has a favourable geometry: a juvenile flatfish observed from above. The acquisition setup is fixed. The conveyor provides a strong chromatic prior. The required output is geometric, not semantic. The system does not need to understand a complex scene; it needs to isolate one fish and measure its shape.

That makes deterministic computer vision highly competitive.

The point is not that deterministic vision is universally better than deep learning. It is not.

The point is that industrial engineering is about fit. If the visual problem has stable geometry, a controlled acquisition path and strong background priors, a classical computer vision pipeline can be accurate, explainable and easier to deploy.

The design choice behind FLATCLASS.

Step 1: stabilising the image before segmentation

Before segmentation, the image is filtered to reduce noise, preserve object boundaries and stabilise local contrast.

This is especially relevant in aquaculture environments, where reflections, water droplets and non-uniform lighting can affect the image.

The preprocessing stage combines three operations:

  1. Non-local means denoising reduces colour noise.
  2. Bilateral filtering smooths the image while preserving edges.
  3. CLAHE enhances local contrast on the value channel of the HSV colour space.
import cv2
import numpy as np

def prefilter(bgr: np.ndarray) -> np.ndarray:
    denoised = cv2.fastNlMeansDenoisingColored(
        bgr,
        None,
        h=5,
        hColor=5,
        templateWindowSize=7,
        searchWindowSize=21
    )
    filtered = cv2.bilateralFilter(
        denoised,
        d=9,
        sigmaColor=50,
        sigmaSpace=50
    )
    hsv = cv2.cvtColor(filtered, cv2.COLOR_BGR2HSV)
    h, s, v = cv2.split(hsv)
    clahe = cv2.createCLAHE(
        clipLimit=2.0,
        tileGridSize=(8, 8)
    )
    v = clahe.apply(v)
    return cv2.cvtColor(
        cv2.merge([h, s, v]),
        cv2.COLOR_HSV2BGR
    )

This step is deliberately conservative. It does not try to infer the fish shape. It improves the stability of the visual signal before segmentation.

That distinction matters. In deterministic industrial vision, every transformation should have a clear function. Here, the purpose is not to make the image prettier. The purpose is to make the subsequent segmentation more stable.

Step 2: turning the conveyor belt into a segmentation prior

Overhead image of a juvenile flatfish on the conveyor belt. The blue belt is used as a chromatic prior for automatic ROI detection.

Overhead image of a juvenile flatfish on the conveyor belt. The blue belt is used as a chromatic prior for automatic ROI detection.

Standard GrabCut is an interactive algorithm. In the original workflow, a user draws a rectangle around the object, and the algorithm separates foreground from background.

That is not acceptable in an automated grading line.

FLATCLASS removes the human from the loop by using the conveyor belt as a segmentation prior. In the tested setup, the fish moves over a blue belt. Instead of treating that blue background as a nuisance, the pipeline uses it as information.

The algorithm estimates the dominant blue hue dynamically from the image. This makes the system more tolerant to changes in lighting and camera exposure.

def dynamic_blue_threshold(hsv: np.ndarray):
    H, S, _ = cv2.split(hsv)
    height, width = H.shape

    # Estimate belt colour from the central vertical band.
    x1, x2 = int(width * 0.32), int(width * 0.68)
    hue_band = H[:, x1:x2]
    sat_band = S[:, x1:x2]
    valid_pixels = hue_band[sat_band > 80]
    if valid_pixels.size == 0:
        lower = np.array([95, 100, 30], dtype=np.uint8)
        upper = np.array([125, 255, 255], dtype=np.uint8)
    else:
        hist = cv2.calcHist(
            [valid_pixels.astype(np.uint8)],
            [0],
            None,
            [180],
            [0, 180]
        ).flatten()
        peak_hue = int(np.argmax(hist))
        half_window = 15
        lower = np.array(
            [max(0, peak_hue - half_window), 80, 30],
            dtype=np.uint8
        )
        upper = np.array(
            [min(179, peak_hue + half_window), 255, 255],
            dtype=np.uint8
        )
    return lower, upper

This is one of the key engineering decisions of the system: the conveyor belt provides a stable reference for separating fish from background without training data.

The background becomes part of the measurement system.

Step 3: constraining the problem with an automatic ROI

Once the blue belt is detected, the system estimates the region of interest.

This is important because industrial images often include more than the animal: metallic structures, conveyor borders, shadows, supports and external elements. These areas should not influence segmentation.

The ROI is derived from the belt geometry and the dominant chromatic structure of the scene.

def longest_true_run(values: np.ndarray):
    best = None
    start = None

    for i, value in enumerate(values):
        if value and start is None:
            start = i
        if (not value or i == len(values) - 1) and start is not None:
            end = i if value else i - 1
            if best is None or (end - start) > (best[1] - best[0]):
                best = (start, end)
            start = None
    return best

def belt_roi_from_blue(
    hsv: np.ndarray,
    lower: np.ndarray,
    upper: np.ndarray,
    min_col_coverage: float = 0.22,
    pad: int = 12
):

    height, width = hsv.shape[:2]
    blue = cv2.inRange(hsv, lower, upper)
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    blue = cv2.morphologyEx(blue, cv2.MORPH_OPEN, kernel)
    blue = cv2.medianBlur(blue, 3)
    col_coverage = (blue > 0).sum(axis=0) / float(height)
    valid_cols = col_coverage >= min_col_coverage
    run = longest_true_run(valid_cols)
    if run is None:
        raise RuntimeError("The conveyor belt could not be detected.")
    x1, x2 = run
    blue_slice = blue[:, x1:x2 + 1]
    row_coverage = (blue_slice > 0).sum(axis=1)
    valid_rows = row_coverage >= max(1, int(0.02 * (x2 - x1 + 1)))
    row_run = longest_true_run(valid_rows)
    y1, y2 = (0, height - 1) if row_run is None else row_run
    x = max(0, x1 - pad)
    y = max(0, y1 - pad)
    w = min(width - x, (x2 - x1 + 1) + 2 * pad)
    h = min(height - y, (y2 - y1 + 1) + 2 * pad)

    return (x, y, w, h), blue

The ROI is not manually defined. It is inferred from the scene itself.

This reduces the segmentation search space and helps make the rest of the pipeline more robust.

Step 4: making GrabCut fully automatic

GrabCut works by assigning pixels to classes such as definite background, probable background, probable foreground and definite foreground.

The quality of those initial labels strongly affects the final segmentation.

In this pipeline, blue conveyor pixels are labelled as definite background. Non-blue regions inside the ROI are expanded as probable foreground. High-contrast regions become stronger foreground candidates. Borders are forced to background to stabilise the optimisation.

def build_grabcut_seeds(
    bgr_roi: np.ndarray,
    lower: np.ndarray,
    upper: np.ndarray):

    hsv = cv2.cvtColor(bgr_roi, cv2.COLOR_BGR2HSV)

    blue = cv2.inRange(hsv, lower, upper)
    not_blue = cv2.bitwise_not(blue)
    kernel = cv2.getStructuringElement(
        cv2.MORPH_ELLIPSE,
        (7, 19)
    )
    probable_fg = cv2.dilate(
        not_blue,
        kernel,
        iterations=1
    )
    h, s, _ = cv2.split(hsv)
    belt_h = h[blue > 0].astype(np.float32)
    belt_s = s[blue > 0].astype(np.float32)
    if belt_h.size > 0:
        median_h = float(np.median(belt_h))
        median_s = float(np.median(belt_s))
        dh = cv2.absdiff(
            h.astype(np.int16),
            np.full_like(h, int(median_h), np.int16)
        ).astype(np.uint8)
        ds = cv2.absdiff(
            s.astype(np.int16),
            np.full_like(s, int(median_s), np.int16)
        ).astype(np.uint8)
        contrast = cv2.addWeighted(dh, 0.7, ds, 0.3, 0)
        threshold = int(np.percentile(contrast, 70))
        _, high_contrast = cv2.threshold(
            contrast,
            threshold,
            255,
            cv2.THRESH_BINARY
        )
        sure_fg = cv2.bitwise_and(high_contrast, not_blue)
    else:
        sure_fg = cv2.erode(
            probable_fg,
            cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)),
            iterations=1
        )
    gc_mask = np.full(
        blue.shape,
        cv2.GC_PR_BGD,
        dtype=np.uint8
    )
    gc_mask[blue > 0] = cv2.GC_BGD
    gc_mask[probable_fg > 0] = cv2.GC_PR_FGD
    gc_mask[sure_fg > 0] = cv2.GC_FGD
    # Guard rails: force borders to background.
    margin = 6
    gc_mask[:margin, :] = cv2.GC_BGD
    gc_mask[-margin:, :] = cv2.GC_BGD
    gc_mask[:, :margin] = cv2.GC_BGD
    gc_mask[:, -margin:] = cv2.GC_BGD
    return gc_mask

Once this mask is available, GrabCut can be executed without manual interaction.

bg_model = np.zeros((1, 65), np.float64)
fg_model = np.zeros((1, 65), np.float64)
cv2.grabCut(
    crop,
    gc_mask,
    None,
    bg_model,
    fg_model,
    iterCount=12,
    mode=cv2.GC_INIT_WITH_MASK
)
mask_roi = np.where(
    (gc_mask == cv2.GC_FGD) | (gc_mask == cv2.GC_PR_FGD),
    1,
    0
).astype(np.uint8)

This is the core of the system: an originally semi-automatic algorithm becomes a fully automatic industrial segmentation method.

Binary segmentation mask after automatic GrabCut segmentation and morphological refinement.

Binary segmentation mask after automatic GrabCut segmentation and morphological refinement.

After GrabCut, morphological closing and opening are applied to remove small artefacts and restore contour continuity. Elliptical structuring elements are used because the dorsal outline of juvenile flatfish is smooth and approximately elliptical.

A clean mask is not the final objective. It is the foundation for reliable measurement.

Step 5: selecting the most plausible fish contour

Segmentation in real industrial images is never perfect. Reflections, belt patterns and small artefacts may produce extra contours.

The system therefore does not blindly select any foreground region. It scores candidate contours according to geometric plausibility.

The scoring considers area, aspect ratio and solidity. This favours contours that are consistent with the expected morphology of a dorsally imaged flatfish.

def contour_scores(
    contours,
    roi,
    min_area=5000,
    min_width=32,
    edge_clearance=16,
    expected_log_aspect=0.92,
    aspect_sigma=0.35
):
    x_roi, y_roi, w_roi, h_roi = roi

    scores = []
    for contour in contours:
        area = cv2.contourArea(contour)
        if area < min_area:
            scores.append(-np.inf)
            continue
        x, y, w, h = cv2.boundingRect(contour)
        if min(w, h) < min_width:
            scores.append(-np.inf)
            continue
        touches_left = (x_roi + x) < (x_roi + edge_clearance)
        touches_right = (x_roi + x + w) > (x_roi + w_roi - edge_clearance)
        if touches_left or touches_right:
            scores.append(-np.inf)
            continue
        aspect = max(w, h) / max(1.0, min(w, h))
        aspect_weight = np.exp(
            -((np.log(aspect) - expected_log_aspect) ** 2)
            / (2.0 * aspect_sigma ** 2)
        )
        hull = cv2.convexHull(contour)
        hull_area = cv2.contourArea(hull)
        solidity = area / hull_area if hull_area > 0 else 0.0
        solidity_weight = np.clip(solidity, 0.0, 1.0)
        score = area * aspect_weight * (0.6 + 0.4 * solidity_weight)
        scores.append(float(score))

  return scores

This scoring layer is important in production-like images. It makes the system more robust to reflections, partial masks and small segmentation artefacts.

It is also another example of why deterministic vision can be effective in this application. The system uses biological and geometric knowledge of the object to constrain the computer vision problem.

Step 6: measuring length and width with PCA

Once the fish contour has been selected, the system must measure it correctly.

A naive approach would measure the width and height of the bounding box in image coordinates. That would be fragile because the fish may not be perfectly aligned with the conveyor or camera axes.

Instead, FLATCLASS uses Principal Component Analysis on the contour points.

PCA finds the dominant axes of the silhouette. For a flatfish observed from above, the first principal component corresponds to the longitudinal body direction, while the second component corresponds to the transverse direction.

The result is a rotation-invariant measurement frame. The system measures the fish according to its own geometry, not according to the orientation of the image.

def measure_with_pca(contour):
    points = contour.reshape(-1, 2).astype(np.float32)
    mean, eigenvectors, _ = cv2.PCACompute2(points, mean=None)
    center = mean.flatten()
    rotated = (points - center) @ eigenvectors.T
    min_x, max_x = rotated[:, 0].min(), rotated[:, 0].max()
    min_y, max_y = rotated[:, 1].min(), rotated[:, 1].max()
    length_px = float(max_x - min_x)
    width_px = float(max_y - min_y)
    area_px2 = float(cv2.contourArea(contour))
    return {
        "length_px": length_px,
        "width_px": width_px,
        "area_px2": area_px2,
        "center": center,
        "axes": eigenvectors
    }

PCA-based measurement overlay. The principal axis follows the fish geometry rather than the image coordinate system.

PCA-based measurement overlay. The principal axis follows the fish geometry rather than the image coordinate system.

This visual overlay is useful during development and validation. The final contour, PCA-aligned box and principal axis allow engineers to check whether the measurement frame is consistent with the real fish geometry.

In production, the overlay is not strictly necessary. The system can compute and publish the measurements directly.

From pixels to millimetres

Pixel measurements are not enough. The system must return physical dimensions.

In many laboratory workflows, this would be done with a checkerboard or physical calibration target. In humid industrial environments, that is not ideal. Calibration targets may be affected by condensation, splashes, fouling or illumination changes.

FLATCLASS uses a pinhole camera formulation instead. If the camera geometry is fixed, the pixel-to-millimetre scale can be estimated from the camera-to-object distance, the sensor pixel pitch and the focal length:

scale_mm_per_px = (H × p) / f

Where:

  • H is the camera-to-object distance.
  • p is the sensor pixel pitch.
  • f is the focal length.

Once the scale is known, conversion is straightforward.

def convert_to_metric(
    length_px,
    width_px,
    area_px2,
    mm_per_px
):
    return {
        "length_mm": length_px * mm_per_px,
        "width_mm": width_px * mm_per_px,
        "area_mm2": area_px2 * (mm_per_px ** 2)
    }

This approach avoids the need for repeated physical calibration captures on the production line, provided that the acquisition geometry remains fixed.

Making the measurement useful: Node-RED, MongoDB and MQTT

A vision system is only valuable when its output can be used operationally.

The final result of the pipeline is not just an annotated image. It is structured data. Each measurement can be timestamped, stored and published to other systems.

A compact record may look like this:

{
  "timestamp": "2025-12-01T10:15:22Z",
  "metrics": {
    "pixels": {
      "length": 745.0,
      "width": 293.0,
      "surface": 184320.0
    },
    "mm": {
      "length": 82.14,
      "width": 32.30,
      "surface": 2240.15
    }
  }
}

Node-RED provides orchestration between acquisition, processing and industrial integration. MongoDB stores historical measurements for traceability and analysis. MQTT allows low-latency publication of results to dashboards, control logic or downstream automation.

This is where computer vision becomes industrial data engineering. The output is no longer a visual artefact. It is a real-time production signal.

Accuracy without GPU acceleration

Assessment of morphometric accuracy and agreement between reference morphological measurements and the vision system. Panels (a) and (b) show scatter plots grouped along the identity line, indicating strong concordance. Panels © and (d) present Bland–Altman analyses confirming minimal bias and narrow limits of agreement across the dataset.

Assessment of morphometric accuracy and agreement between reference morphological measurements and the vision system. Panels (a) and (b) show scatter plots grouped along the identity line, indicating strong concordance. Panels © and (d) present Bland–Altman analyses confirming minimal bias and narrow limits of agreement across the dataset.

The system was evaluated using a dataset comprising 500 juvenile flatfish, whose measurements were taken manually for reference purposes. The individuals ranged in length from 33 to 114 mm, which corresponds to a weight range of 0.5 to 22 g.

In the evaluated setup, the deterministic vision pipeline achieved high agreement with ground-truth measurements.

Length estimation remained within approximately 1% of the reference values. Width estimation reached sub-millimetre precision, with even lower absolute error.

The deployed system also operated at approximately 2 fish per second without GPU acceleration.

That combination is important. The result is not only accurate; it is computationally lightweight. For industrial deployment, reducing hardware dependency can simplify installation, maintenance and scaling.

What this pipeline teaches about industrial vision

The FLATCLASS pipeline offers several lessons that apply beyond aquaculture.

First, geometry matters. If the object has a predictable shape and acquisition is controlled, classical vision can still be a strong engineering choice.

Second, the background is not always noise. In this case, the blue conveyor belt becomes a chromatic prior that makes automatic segmentation possible.

Third, explainability is operational value. A deterministic pipeline can be inspected step by step: preprocessing, ROI detection, mask construction, segmentation, contour selection and PCA measurement. That is useful when debugging a production system.

Fourth, low dependency is an advantage. Avoiding annotated datasets, retraining cycles and GPU inference reduces maintenance overhead.

Finally, industrial AI is not always about choosing the most advanced model. Often, it is about choosing the most appropriate architecture for the constraints of the environment.

Conclusion

Deep learning remains a powerful tool for aquaculture vision systems, especially when morphology is complex, scenes are unconstrained or semantic interpretation is required.

But in industrial measurement problems with stable geometry, controlled acquisition and strong visual priors, deterministic computer vision can still deliver high accuracy, low latency and operational transparency.

FLATCLASS shows that classical vision pipelines are not outdated. When engineered carefully, they remain a practical foundation for real-time precision aquaculture.

The broader lesson is simple: neural networks are not the default answer to every vision problem. Sometimes, the best industrial AI system is the one that measures accurately, runs reliably and can be understood end to end.


메타데이터
post_id
70a343ca2d24
slug
measuring-flatfish-without-neural-networks-70a343ca2d24
url
https://medium.com/towards-data-engineering/measuring-flatfish-without-neural-networks-70a343ca2d24
canonical_url
https://medium.com/towards-data-engineering/measuring-flatfish-without-neural-networks-70a343ca2d24
author_url
https://medium.com/@javier.osuna_53292
status
ok
fetched_at
2026-07-13 06:23:13