| """ |
| Orchestration layer: run the full QC analysis on one image or a batch, and |
| assemble the artefacts (scores, maps, descriptors) the UI needs. |
| """ |
|
|
| from __future__ import annotations |
| import numpy as np |
| import cv2 |
| from .fov import detect_fov |
| from .qc_metrics import compute_all_metrics, METRIC_WEIGHTS |
| from .quality_score import composite_score |
| from .failure_analysis import composite_problem_map, fov_overlay, per_axis_heatmap |
| from .batch_effects import descriptor_from_metrics |
|
|
| METRIC_NAMES = list(METRIC_WEIGHTS.keys()) |
|
|
|
|
| def _to_rgb(img): |
| a = np.asarray(img) |
| if a.ndim == 2: |
| a = cv2.cvtColor(a, cv2.COLOR_GRAY2RGB) |
| if a.shape[-1] == 4: |
| a = a[..., :3] |
| return np.ascontiguousarray(a.astype(np.uint8)) |
|
|
|
|
| def degradation_map_from_metrics(shape, metrics): |
| """Aggregate the spatial maps of failing/borderline axes into one 0-1 map.""" |
| dmap = np.zeros(shape[:2], np.float32) |
| for m in metrics: |
| if m.get("_map") is not None and m["score"] < 0.66: |
| w = (0.66 - m["score"]) |
| dmap = np.maximum(dmap, m["_map"].astype(np.float32) * w) |
| if dmap.max() > 0: |
| dmap /= dmap.max() |
| return dmap |
|
|
|
|
| def analyze_image(img, name="image", with_vessels=True): |
| """Full single-image analysis. Returns a dict bundle.""" |
| rgb = _to_rgb(img) |
| fov = detect_fov(rgb) |
| metrics = compute_all_metrics(rgb, fov) |
| summary = composite_score(metrics) |
| problem_overlay, problem_caption = composite_problem_map(rgb, fov, metrics, summary) |
| descriptor = descriptor_from_metrics(rgb, metrics, fov) |
| dmap = degradation_map_from_metrics(rgb.shape, metrics) |
| bundle = dict( |
| name=name, rgb=rgb, fov=fov, metrics=metrics, summary=summary, |
| problem_overlay=problem_overlay, problem_caption=problem_caption, |
| fov_overlay=fov_overlay(rgb, fov), descriptor=descriptor, |
| degradation_map=dmap, |
| ) |
| if with_vessels: |
| try: |
| from .vessels import analyze_vessels, vessel_gradability_score |
| v = analyze_vessels(rgb, fov) |
| v["gradability"], v["parts"] = vessel_gradability_score(v) |
| bundle["vessels"] = v |
| except Exception as e: |
| bundle["vessels_error"] = str(e) |
| return bundle |
|
|
|
|
| def analyze_batch(images, names=None, progress=None): |
| """Analyse a list of images. `images` may be file paths or arrays.""" |
| from PIL import Image |
| results = [] |
| n = len(images) |
| for i, im in enumerate(images): |
| if progress is not None: |
| progress((i + 1) / max(n, 1), desc=f"Analysing {i+1}/{n}") |
| if isinstance(im, str): |
| nm = names[i] if names else im.split("/")[-1] |
| arr = np.array(Image.open(im).convert("RGB")) |
| else: |
| nm = names[i] if names else f"image_{i:03d}" |
| arr = _to_rgb(im) |
| try: |
| results.append(analyze_image(arr, nm)) |
| except Exception as e: |
| results.append(dict(name=nm, error=str(e), rgb=_to_rgb(arr))) |
| return results |
|
|
|
|
| def results_to_dataframe(results): |
| import pandas as pd |
| rows = [] |
| for r in results: |
| if "error" in r: |
| rows.append(dict(image=r["name"], composite=np.nan, verdict="ERROR")) |
| continue |
| row = dict(image=r["name"], |
| composite=round(r["summary"]["composite"], 1), |
| verdict=r["summary"]["verdict"], |
| band=r["summary"]["band"], |
| primary_reason=r["summary"]["primary_reason"]) |
| for m in r["metrics"]: |
| row[m["name"]] = round(m["score"], 3) |
| rows.append(row) |
| return pd.DataFrame(rows) |
|
|
|
|
| def metric_table(result): |
| """Per-axis table for one image (list of rows).""" |
| rows = [] |
| for m in result["metrics"]: |
| rows.append([m["name"], f'{m["value"]:.2f} {m["unit"]}', |
| f'{m["score"]:.2f}', m["status"].upper(), m["reason"]]) |
| return rows |
|
|