Files
E01_textural_features_blast…/src/features/feature_registry.py
T
2026-09-11 08:28:02 +03:00

318 lines
13 KiB
Python

from __future__ import annotations
import argparse
import os
from pathlib import Path
from utils.project import CROPS_ROOT, FEATURES_ROOT, REPORTS_ROOT, TABLES_ROOT, ensure_output_layout, write_markdown
from features.extract_color import color_features
from features.extract_morphology import morphology_from_bbox, morphology_from_mask
from features.extract_texture_glcm import texture_glcm_features
from features.extract_texture_lbp import texture_lbp_features
from features.extract_texture_wavelet import texture_wavelet_features
from preprocessing.image_io import load_binary_mask, load_rgb_image
import numpy as np
import pandas as pd
from joblib import Parallel, delayed
ROI_MANIFEST_PATH = CROPS_ROOT / "roi_manifest.parquet"
KEY_COLUMNS = {"patient_id", "image_id", "object_id_within_image", "class_id", "class_name", "has_mask"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Extract A5 feature matrices.")
parser.add_argument("--roi-manifest", type=Path, default=ROI_MANIFEST_PATH)
parser.add_argument("--output-suffix", type=str, default="")
parser.add_argument("--texture-profile", choices=["full", "lite"], default="full")
return parser.parse_args()
def _gray(image_rgb: np.ndarray) -> np.ndarray:
return np.dot(image_rgb[..., :3].astype(np.float32), np.array([0.299, 0.587, 0.114], dtype=np.float32))
def _record_key(row: dict[str, object]) -> dict[str, object]:
return {
"patient_id": row["patient_id"],
"image_id": row["image_id"],
"object_id_within_image": int(row["object_id_within_image"]),
"class_id": int(row["class_id"]),
"class_name": row["class_name"],
"has_mask": bool(row["has_mask"]),
}
def _texture_bundle(
gray_image: np.ndarray,
binary_mask: np.ndarray | None,
texture_profile: str,
) -> dict[str, float]:
features: dict[str, float] = {}
if texture_profile == "full":
features.update(texture_glcm_features(gray_image, binary_mask=binary_mask))
features.update(texture_lbp_features(gray_image, binary_mask=binary_mask))
features.update(texture_wavelet_features(gray_image, binary_mask=binary_mask))
return features
features.update(texture_lbp_features(gray_image, binary_mask=binary_mask))
return features
def _feature_family(feature_name: str) -> str:
if feature_name == "mask_missing":
return "quality_flag"
normalized = feature_name
if normalized.startswith("bbox_"):
normalized = normalized[len("bbox_") :]
elif normalized.startswith("mask_"):
normalized = normalized[len("mask_") :]
if normalized.startswith("morph_"):
return "morphology"
if normalized.startswith(("color_", "hsv_", "lab_")):
return "color"
if normalized.startswith(("texture_", "wavelet_")):
return "texture"
return "other"
def _feature_region(feature_name: str, matrix_name: str) -> str:
if feature_name.startswith("bbox_"):
return "bbox_crop"
if feature_name.startswith("mask_"):
return "mask_crop"
if matrix_name == "bbox":
return "bbox_crop"
if matrix_name == "mask":
return "mask_crop"
return "combined"
def _feature_description(feature_name: str) -> str:
normalized = feature_name
if normalized.startswith("bbox_"):
normalized = normalized[len("bbox_") :]
elif normalized.startswith("mask_"):
normalized = normalized[len("mask_") :]
if normalized == "mask_missing":
return "Indicator that mask-derived features are unavailable for this object."
if normalized.startswith("morph_bbox_area_px"):
return "Bounding-box area in pixels."
if normalized.startswith("morph_bbox_width_px"):
return "Bounding-box width in pixels."
if normalized.startswith("morph_bbox_height_px"):
return "Bounding-box height in pixels."
if normalized.startswith("morph_bbox_perimeter_px"):
return "Bounding-box perimeter in pixels."
if normalized.startswith("morph_bbox_aspect_ratio"):
return "Bounding-box aspect ratio."
if normalized.startswith("morph_bbox_diagonal_px"):
return "Bounding-box diagonal length in pixels."
if normalized.startswith("morph_area_px"):
return "Mask area in pixels."
if normalized.startswith("morph_perimeter_px"):
return "Mask perimeter in pixels."
if normalized.startswith("morph_extent"):
return "Mask extent relative to its enclosing box."
if normalized.startswith("morph_eccentricity"):
return "Mask eccentricity from regionprops."
if normalized.startswith("morph_solidity"):
return "Mask solidity from regionprops."
if normalized.startswith("morph_roundness"):
return "Roundness computed as 4*pi*area/perimeter^2."
if normalized.startswith("morph_bbox_fill_ratio"):
return "Ratio between mask area and enclosing bounding-box area."
if normalized.startswith("color_"):
return "RGB channel summary statistic computed on selected pixels."
if normalized.startswith("hsv_"):
return "HSV channel summary statistic computed on selected pixels."
if normalized.startswith("lab_"):
return "Lab channel summary statistic computed on selected pixels."
if normalized.startswith("texture_glcm_"):
return "GLCM/Haralick-style texture statistic."
if normalized.startswith("texture_lbp_"):
return "Local Binary Pattern histogram bin."
if normalized.startswith("texture_sobel_"):
return "Sobel-gradient summary statistic."
if normalized.startswith("texture_laplace_"):
return "Laplacian summary statistic."
if normalized.startswith("wavelet_"):
return "Wavelet coefficient summary statistic."
return "Derived feature from the configured extraction pipeline."
def _build_feature_dictionary(matrix_name: str, frame: pd.DataFrame) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for column in frame.columns:
if column in KEY_COLUMNS:
continue
rows.append(
{
"matrix": matrix_name,
"feature_name": column,
"feature_family": _feature_family(column),
"source_region": _feature_region(column, matrix_name),
"description": _feature_description(column),
}
)
return rows
def _build_missingness_rows(matrix_name: str, frame: pd.DataFrame) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for column in frame.columns:
if column in KEY_COLUMNS:
continue
rows.append(
{
"matrix": matrix_name,
"feature_name": column,
"missing_fraction": float(frame[column].isna().mean()),
"missing_count": int(frame[column].isna().sum()),
"is_constant": bool(frame[column].nunique(dropna=False) <= 1),
}
)
return rows
def _extract_single_row(
row: dict[str, object],
texture_profile: str,
) -> tuple[dict[str, object], dict[str, object] | None, dict[str, object]]:
bbox_rgb = load_rgb_image(row["bbox_crop_path"])
bbox_gray = _gray(bbox_rgb)
bbox_features = {}
bbox_features.update(morphology_from_bbox(int(row["bbox_width_px"]), int(row["bbox_height_px"])))
bbox_features.update(color_features(bbox_rgb))
bbox_features.update(_texture_bundle(bbox_gray, binary_mask=None, texture_profile=texture_profile))
bbox_prefixed = {f"bbox_{key}": value for key, value in bbox_features.items()}
bbox_row = {**_record_key(row), **bbox_prefixed}
combined_row = {**_record_key(row), **bbox_prefixed}
mask_row: dict[str, object] | None = None
if row["has_mask"] and row["mask_crop_path"]:
full_mask = load_binary_mask(row["mask_path"])
bbox = (
int(row["bbox_x1"]),
int(row["bbox_y1"]),
int(row["bbox_x2"]),
int(row["bbox_y2"]),
)
mask_crop = full_mask[bbox[1] : bbox[3], bbox[0] : bbox[2]]
mask_rgb = load_rgb_image(row["mask_crop_path"])
mask_gray = _gray(mask_rgb)
mask_features = {}
mask_features.update(morphology_from_mask(mask_crop))
mask_features.update(color_features(mask_rgb, binary_mask=mask_crop))
mask_features.update(_texture_bundle(mask_gray, binary_mask=mask_crop, texture_profile=texture_profile))
mask_prefixed = {f"mask_{key}": value for key, value in mask_features.items()}
mask_row = {**_record_key(row), **mask_prefixed}
combined_row.update(mask_prefixed)
else:
combined_row["mask_missing"] = 1
return bbox_row, mask_row, combined_row
def extract_features(
roi_manifest_path: Path,
output_suffix: str = "",
texture_profile: str = "full",
) -> dict[str, str]:
ensure_output_layout()
roi_df = pd.read_parquet(roi_manifest_path)
roi_records = roi_df.to_dict("records")
n_jobs = min(8, max(1, os.cpu_count() or 1))
extracted_rows = Parallel(n_jobs=n_jobs, prefer="threads", batch_size=64)(
delayed(_extract_single_row)(row, texture_profile) for row in roi_records
)
bbox_rows = [bbox_row for bbox_row, _, _ in extracted_rows]
mask_rows = [mask_row for _, mask_row, _ in extracted_rows if mask_row is not None]
combined_rows = [combined_row for _, _, combined_row in extracted_rows]
features_bbox_df = pd.DataFrame(bbox_rows)
features_mask_df = pd.DataFrame(mask_rows)
features_combined_df = pd.DataFrame(combined_rows)
suffix = output_suffix.strip()
features_bbox_path = FEATURES_ROOT / f"features_bbox{suffix}.parquet"
features_mask_path = FEATURES_ROOT / f"features_mask{suffix}.parquet"
features_combined_path = FEATURES_ROOT / f"features_combined{suffix}.parquet"
features_bbox_df.to_parquet(features_bbox_path, index=False)
features_mask_df.to_parquet(features_mask_path, index=False)
features_combined_df.to_parquet(features_combined_path, index=False)
dictionary_rows = []
for frame_name, frame in [("bbox", features_bbox_df), ("mask", features_mask_df), ("combined", features_combined_df)]:
dictionary_rows.extend(_build_feature_dictionary(frame_name, frame))
feature_dictionary_csv = FEATURES_ROOT / f"feature_dictionary{suffix}.csv"
pd.DataFrame(dictionary_rows).to_csv(feature_dictionary_csv, index=False)
dictionary_lines = [
"# Feature Dictionary",
"",
"| Matrix | Feature name | Family | Source region | Description |",
"|---|---|---|---|---|",
]
for row in dictionary_rows:
dictionary_lines.append(
f"| {row['matrix']} | {row['feature_name']} | {row['feature_family']} | {row['source_region']} | {row['description']} |"
)
feature_dictionary_md = FEATURES_ROOT / f"feature_dictionary{suffix}.md"
write_markdown(feature_dictionary_md, dictionary_lines)
missingness_rows = []
for frame_name, frame in [("bbox", features_bbox_df), ("mask", features_mask_df), ("combined", features_combined_df)]:
missingness_rows.extend(_build_missingness_rows(frame_name, frame))
missingness_df = pd.DataFrame(missingness_rows)
missingness_path = TABLES_ROOT / f"feature_missingness{suffix}.csv"
missingness_df.to_csv(missingness_path, index=False)
combined_missingness_df = missingness_df.loc[missingness_df["matrix"].eq("combined")].copy()
report_lines = [
"# A5 Feature Extraction QC",
"",
f"Generated at: `{pd.Timestamp.utcnow().isoformat()}`",
"",
f"- BBox feature rows: `{len(features_bbox_df)}`",
f"- Mask feature rows: `{len(features_mask_df)}`",
f"- Combined feature rows: `{len(features_combined_df)}`",
f"- Parallel workers used: `{n_jobs}`",
f"- Texture profile: `{texture_profile}`",
f"- Numeric feature count (combined): `{int(len(combined_missingness_df))}`",
f"- Combined features with missing values: `{int((combined_missingness_df['missing_fraction'] > 0).sum())}`",
f"- Combined constant features: `{int(combined_missingness_df['is_constant'].sum())}`",
f"- Total dictionary rows: `{len(dictionary_rows)}`",
]
qc_report_path = REPORTS_ROOT / f"feature_extraction_qc{suffix}.md"
write_markdown(qc_report_path, report_lines)
return {
"features_bbox": str(features_bbox_path),
"features_mask": str(features_mask_path),
"features_combined": str(features_combined_path),
"feature_dictionary_csv": str(feature_dictionary_csv),
"feature_dictionary_md": str(feature_dictionary_md),
"feature_missingness_csv": str(missingness_path),
"feature_extraction_qc_md": str(qc_report_path),
}
def main() -> None:
args = parse_args()
result = extract_features(
args.roi_manifest,
output_suffix=args.output_suffix,
texture_profile=args.texture_profile,
)
print(result)
if __name__ == "__main__":
main()