pixel-patrol-image 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pixel_patrol_image-0.1.0/PKG-INFO +8 -0
- pixel_patrol_image-0.1.0/pyproject.toml +32 -0
- pixel_patrol_image-0.1.0/setup.cfg +4 -0
- pixel_patrol_image-0.1.0/src/pixel_patrol_image/plugin_registry.py +14 -0
- pixel_patrol_image-0.1.0/src/pixel_patrol_image/plugins/processors/quality_metrics_processor.py +136 -0
- pixel_patrol_image-0.1.0/src/pixel_patrol_image/plugins/widgets/dynamic_quality_metrics.py +23 -0
- pixel_patrol_image-0.1.0/src/pixel_patrol_image/plugins/widgets/image_quality.py +81 -0
- pixel_patrol_image-0.1.0/src/pixel_patrol_image.egg-info/PKG-INFO +8 -0
- pixel_patrol_image-0.1.0/src/pixel_patrol_image.egg-info/SOURCES.txt +11 -0
- pixel_patrol_image-0.1.0/src/pixel_patrol_image.egg-info/dependency_links.txt +1 -0
- pixel_patrol_image-0.1.0/src/pixel_patrol_image.egg-info/entry_points.txt +5 -0
- pixel_patrol_image-0.1.0/src/pixel_patrol_image.egg-info/requires.txt +2 -0
- pixel_patrol_image-0.1.0/src/pixel_patrol_image.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pixel-patrol-image"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Image prevalidation tool"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pixel-patrol-base>=0.1.0",
|
|
9
|
+
# "opencv-python>=4.11.0.88",
|
|
10
|
+
"opencv-python-headless>=4.8",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.uv]
|
|
14
|
+
package = true
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/pixel_patrol_image"]
|
|
18
|
+
|
|
19
|
+
[project.entry-points."pixel_patrol.processor_plugins"]
|
|
20
|
+
pixel_patrol_image_processing_builtins = "pixel_patrol_image.plugin_registry:register_processor_plugins"
|
|
21
|
+
|
|
22
|
+
[project.entry-points."pixel_patrol.widget_plugins"]
|
|
23
|
+
pixel_patrol_image_widget_builtins = "pixel_patrol_image.plugin_registry:register_widget_plugins"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=8.3.5",
|
|
28
|
+
"pytest-mock>=3.14.1",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.uv.sources]
|
|
32
|
+
pixel-patrol-base = { path = "../pixel-patrol-base" }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pixel_patrol_image.plugins.processors.quality_metrics_processor import QualityMetricsProcessor
|
|
2
|
+
from pixel_patrol_image.plugins.widgets.image_quality import ImageQualityWidget
|
|
3
|
+
from pixel_patrol_image.plugins.widgets.dynamic_quality_metrics import DynamicQualityMetricsWidget
|
|
4
|
+
|
|
5
|
+
def register_processor_plugins():
|
|
6
|
+
return [
|
|
7
|
+
QualityMetricsProcessor,
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
def register_widget_plugins():
|
|
11
|
+
return [
|
|
12
|
+
ImageQualityWidget,
|
|
13
|
+
DynamicQualityMetricsWidget,
|
|
14
|
+
]
|
pixel_patrol_image-0.1.0/src/pixel_patrol_image/plugins/processors/quality_metrics_processor.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, Callable, Any, Optional, List, Tuple
|
|
3
|
+
|
|
4
|
+
import cv2
|
|
5
|
+
import dask.array as da
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from pixel_patrol_base.utils.array_utils import calculate_sliced_stats
|
|
9
|
+
from pixel_patrol_base.core.record import Record
|
|
10
|
+
from pixel_patrol_base.core.specs import RecordSpec
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _column_fn_registry() -> Dict[str, Dict[str, Callable]]:
|
|
16
|
+
return {
|
|
17
|
+
"laplacian_variance": {"fn": _variance_of_laplacian_2d, "agg": da.mean},
|
|
18
|
+
"tenengrad": {"fn": _tenengrad_2d, "agg": da.mean},
|
|
19
|
+
"brenner": {"fn": _brenner_2d, "agg": da.mean},
|
|
20
|
+
"noise_std": {"fn": _noise_estimation_2d, "agg": da.mean},
|
|
21
|
+
"blocking_records": {"fn": _check_blocking_records_2d, "agg": da.mean},
|
|
22
|
+
"ringing_records": {"fn": _check_ringing_records_2d, "agg": da.mean},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def calculate_np_array_stats(array: da.array, dim_order: str) -> Dict[str, float]:
|
|
27
|
+
registry = _column_fn_registry()
|
|
28
|
+
all_metrics = {k: v["fn"] for k, v in registry.items()}
|
|
29
|
+
all_aggregators = {k: v["agg"] for k, v in registry.items() if v["agg"] is not None}
|
|
30
|
+
return calculate_sliced_stats(array, dim_order, all_metrics, all_aggregators)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _prepare_2d_image(image: np.ndarray) -> Optional[np.ndarray]:
|
|
34
|
+
if image.ndim != 2 or image.size == 0 or image.dtype == bool:
|
|
35
|
+
return None
|
|
36
|
+
return image.astype(np.float32)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _variance_of_laplacian_2d(image: np.ndarray) -> float:
|
|
40
|
+
image = _prepare_2d_image(image)
|
|
41
|
+
if image is None:
|
|
42
|
+
return float(np.nan)
|
|
43
|
+
lap = cv2.Laplacian(image, cv2.CV_32F)
|
|
44
|
+
return float(lap.var())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _tenengrad_2d(image: np.ndarray) -> float:
|
|
48
|
+
image = _prepare_2d_image(image)
|
|
49
|
+
if image is None or np.all(image == image.flat[0]):
|
|
50
|
+
return float(np.nan)
|
|
51
|
+
gx = cv2.Sobel(image, cv2.CV_32F, 1, 0, ksize=3)
|
|
52
|
+
gy = cv2.Sobel(image, cv2.CV_32F, 0, 1, ksize=3)
|
|
53
|
+
mag = np.sqrt(gx ** 2 + gy ** 2)
|
|
54
|
+
return float(np.mean(mag)) if mag.size > 0 else 0.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _brenner_2d(image: np.ndarray) -> float:
|
|
58
|
+
image = _prepare_2d_image(image)
|
|
59
|
+
if image is None:
|
|
60
|
+
return float(np.nan)
|
|
61
|
+
diff = image[:, 2:] - image[:, :-2]
|
|
62
|
+
return float(np.mean(diff ** 2)) if diff.size > 0 else 0.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _noise_estimation_2d(image: np.ndarray) -> float:
|
|
66
|
+
image = _prepare_2d_image(image)
|
|
67
|
+
if image is None:
|
|
68
|
+
return float(np.nan)
|
|
69
|
+
median = cv2.medianBlur(image, 3)
|
|
70
|
+
noise = image - median
|
|
71
|
+
return float(np.std(noise))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_blocking_records_2d(image: np.ndarray) -> float:
|
|
75
|
+
image = _prepare_2d_image(image)
|
|
76
|
+
if image is None:
|
|
77
|
+
return float(np.nan)
|
|
78
|
+
|
|
79
|
+
block_size = 8
|
|
80
|
+
height, width = image.shape
|
|
81
|
+
blocking_effect = 0.0
|
|
82
|
+
num_boundaries = 0
|
|
83
|
+
|
|
84
|
+
for i in range(block_size, height, block_size):
|
|
85
|
+
if i < height:
|
|
86
|
+
blocking_effect += float(np.mean(np.abs(image[i, :] - image[i - 1, :])))
|
|
87
|
+
num_boundaries += 1
|
|
88
|
+
for j in range(block_size, width, block_size):
|
|
89
|
+
if j < width:
|
|
90
|
+
blocking_effect += float(np.mean(np.abs(image[:, j] - image[:, j - 1])))
|
|
91
|
+
num_boundaries += 1
|
|
92
|
+
|
|
93
|
+
return blocking_effect / num_boundaries if num_boundaries > 0 else 0.0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _check_ringing_records_2d(image: np.ndarray) -> float:
|
|
97
|
+
image = _prepare_2d_image(image)
|
|
98
|
+
if image is None:
|
|
99
|
+
return float(np.nan)
|
|
100
|
+
normalized_image = cv2.normalize(image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
|
101
|
+
edges = cv2.Canny(normalized_image, 50, 150)
|
|
102
|
+
if np.sum(edges) == 0:
|
|
103
|
+
return 0.0
|
|
104
|
+
|
|
105
|
+
kernel = np.ones((3, 3), np.uint8)
|
|
106
|
+
dilated_edges = cv2.dilate(edges, kernel, iterations=1)
|
|
107
|
+
edge_neighborhood = dilated_edges - edges
|
|
108
|
+
|
|
109
|
+
if np.sum(edge_neighborhood > 0) == 0:
|
|
110
|
+
return 0.0
|
|
111
|
+
|
|
112
|
+
ringing_variance = np.var(image[edge_neighborhood > 0])
|
|
113
|
+
return float(ringing_variance)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class QualityMetricsProcessor:
|
|
117
|
+
"""
|
|
118
|
+
Extracts image quality metrics (tenengrad, brenner, noise, etc.) from XY slices.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
# Declarative plugin metadata
|
|
122
|
+
NAME = "quality-metrics"
|
|
123
|
+
INPUT = RecordSpec(axes={"X", "Y"}, kinds={"intensity"}, capabilities={"spatial-2d"})
|
|
124
|
+
OUTPUT = "features" # or "record" if this produced another image
|
|
125
|
+
|
|
126
|
+
# Table schema (static + dynamic)
|
|
127
|
+
OUTPUT_SCHEMA: Dict[str, Any] = {name: float for name in _column_fn_registry().keys()}
|
|
128
|
+
# e.g. tenengrad_C0_Z3, brenner_T5, etc.
|
|
129
|
+
OUTPUT_SCHEMA_PATTERNS: List[Tuple[str, Any]] = [
|
|
130
|
+
(rf"^(?:{name})_[a-zA-Z]\d+(_[a-zA-Z]\d+)*$", float)
|
|
131
|
+
for name in _column_fn_registry().keys()
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
def run(self, art: Record) -> Dict[str, float]:
|
|
135
|
+
dim_order = art.dim_order
|
|
136
|
+
return calculate_np_array_stats(art.data, dim_order)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import List, Set
|
|
2
|
+
|
|
3
|
+
from pixel_patrol_base.plugins.widgets.base_dynamic_table_widget import BaseDynamicTableWidget
|
|
4
|
+
from pixel_patrol_base.core.feature_schema import patterns_from_processor
|
|
5
|
+
from pixel_patrol_base.report.widget_categories import WidgetCategories
|
|
6
|
+
|
|
7
|
+
from pixel_patrol_image.plugins.processors.quality_metrics_processor import QualityMetricsProcessor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DynamicQualityMetricsWidget(BaseDynamicTableWidget):
|
|
11
|
+
NAME: str = "Quality metrics across dimensions"
|
|
12
|
+
TAB: str = WidgetCategories.DATASET_STATS.value
|
|
13
|
+
|
|
14
|
+
# No fixed columns; rely on the processor's dynamic outputs
|
|
15
|
+
REQUIRES: Set[str] = set()
|
|
16
|
+
REQUIRES_PATTERNS: List[str] = patterns_from_processor(QualityMetricsProcessor)
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
super().__init__(widget_id="quality-stats")
|
|
20
|
+
|
|
21
|
+
def get_supported_metrics(self) -> List[str]:
|
|
22
|
+
# Base metric names expected in dynamic columns (e.g., "snr", "focus", …)
|
|
23
|
+
return list(getattr(QualityMetricsProcessor, "OUTPUT_SCHEMA", {}).keys())
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from typing import List, Dict, Set
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
from dash import html, Input, Output
|
|
5
|
+
|
|
6
|
+
from pixel_patrol_image.plugins.processors.quality_metrics_processor import QualityMetricsProcessor
|
|
7
|
+
from pixel_patrol_base.core.feature_schema import patterns_from_processor
|
|
8
|
+
from pixel_patrol_base.report.utils import generate_column_violin_plots
|
|
9
|
+
from pixel_patrol_base.report.widget_categories import WidgetCategories
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ImageQualityWidget:
|
|
13
|
+
# ---- Declarative spec ----
|
|
14
|
+
NAME: str = "Image Quality"
|
|
15
|
+
TAB: str = WidgetCategories.DATASET_STATS.value
|
|
16
|
+
# Grouping by folder is typical for these plots; require the label column.
|
|
17
|
+
REQUIRES: Set[str] = {"imported_path_short"}
|
|
18
|
+
# Dynamic metric columns come from the processor (regex patterns).
|
|
19
|
+
REQUIRES_PATTERNS: List[str] = patterns_from_processor(QualityMetricsProcessor)
|
|
20
|
+
|
|
21
|
+
# Component IDs
|
|
22
|
+
CONTAINER_ID = "image-quality-container"
|
|
23
|
+
|
|
24
|
+
def get_descriptions(self) -> Dict[str, str]:
|
|
25
|
+
"""Descriptions of image quality metrics shown below."""
|
|
26
|
+
return {
|
|
27
|
+
"laplacian_variance": (
|
|
28
|
+
"Measures sharpness via the variance of the Laplacian (edges). "
|
|
29
|
+
"Higher values indicate sharper images."
|
|
30
|
+
),
|
|
31
|
+
"tenengrad": (
|
|
32
|
+
"Edge strength from Sobel gradients. Higher values often mean better focus."
|
|
33
|
+
),
|
|
34
|
+
"brenner": (
|
|
35
|
+
"Intensity differences between neighboring pixels; higher suggests more fine detail."
|
|
36
|
+
),
|
|
37
|
+
"noise_std": (
|
|
38
|
+
"Estimated random noise level; higher noise reduces clarity."
|
|
39
|
+
),
|
|
40
|
+
# "wavelet_energy": "High-frequency detail via wavelet energy.",
|
|
41
|
+
"blocking_records": (
|
|
42
|
+
"Compression blockiness (e.g., JPEG); higher indicates stronger blocking records."
|
|
43
|
+
),
|
|
44
|
+
"ringing_records": (
|
|
45
|
+
"Edge ghosting/oscillations from compression; higher indicates stronger ringing."
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def layout(self) -> List:
|
|
50
|
+
"""Static description + a single container populated by the callback."""
|
|
51
|
+
description_items = [
|
|
52
|
+
html.Li([html.Strong(f"{k.replace('_', ' ').title()}: "), v])
|
|
53
|
+
for k, v in self.get_descriptions().items()
|
|
54
|
+
]
|
|
55
|
+
return [
|
|
56
|
+
html.Div(
|
|
57
|
+
className="markdown-content",
|
|
58
|
+
children=[
|
|
59
|
+
html.H4("Image Quality Metric Descriptions"),
|
|
60
|
+
html.P(
|
|
61
|
+
"The following metrics assess various aspects of image quality, such as sharpness, noise, "
|
|
62
|
+
"and compression records. Each plot shows the distribution of a quality score for images "
|
|
63
|
+
"in the selected folders."
|
|
64
|
+
),
|
|
65
|
+
html.Ul(description_items),
|
|
66
|
+
],
|
|
67
|
+
),
|
|
68
|
+
html.Hr(),
|
|
69
|
+
html.Div(id=self.CONTAINER_ID),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def register(self, app, df_global: pl.DataFrame):
|
|
73
|
+
"""One callback that renders all violin plots."""
|
|
74
|
+
|
|
75
|
+
@app.callback(
|
|
76
|
+
Output(self.CONTAINER_ID, "children"),
|
|
77
|
+
Input("color-map-store", "data"),
|
|
78
|
+
)
|
|
79
|
+
def update_image_quality_layout(color_map: Dict[str, str]):
|
|
80
|
+
metric_cols = list(self.get_descriptions().keys())
|
|
81
|
+
return generate_column_violin_plots(df_global, color_map or {}, metric_cols)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/pixel_patrol_image/plugin_registry.py
|
|
3
|
+
src/pixel_patrol_image.egg-info/PKG-INFO
|
|
4
|
+
src/pixel_patrol_image.egg-info/SOURCES.txt
|
|
5
|
+
src/pixel_patrol_image.egg-info/dependency_links.txt
|
|
6
|
+
src/pixel_patrol_image.egg-info/entry_points.txt
|
|
7
|
+
src/pixel_patrol_image.egg-info/requires.txt
|
|
8
|
+
src/pixel_patrol_image.egg-info/top_level.txt
|
|
9
|
+
src/pixel_patrol_image/plugins/processors/quality_metrics_processor.py
|
|
10
|
+
src/pixel_patrol_image/plugins/widgets/dynamic_quality_metrics.py
|
|
11
|
+
src/pixel_patrol_image/plugins/widgets/image_quality.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pixel_patrol_image
|