vendor: OpenCV 5.0.0 snapshot at 40738fb16ceddb5fb3fea747585f7ce6abb0605b
This commit is contained in:
@@ -0,0 +1,741 @@
|
||||
# This file is part of OpenCV project.
|
||||
# It is subject to the license terms in the LICENSE file found in the top-level directory
|
||||
# of this distribution and at http://opencv.org/license.html.
|
||||
|
||||
'''
|
||||
Camera calibration for chromatic aberration correction
|
||||
The calibration is done of a photo of black discs on white background.
|
||||
The calibration pattern can be found either in
|
||||
opencv_extra/testdata/cv/cameracalibration/chromatic_aberration/chromatic_aberration_pattern_a3.png,
|
||||
or can be replicated using the script for generating patterns:
|
||||
https://github.com/opencv/opencv/blob/4.x/doc/pattern_tools/gen_pattern.py,
|
||||
using the following invocation:
|
||||
|
||||
python doc/pattern_tools/gen_pattern.py \
|
||||
--output fc4_pattern_A3.svg \
|
||||
--type circles \
|
||||
--rows 26 --columns 37 \
|
||||
--units mm \
|
||||
--square_size 11 \
|
||||
--radius_rate 2.75 \
|
||||
--page_width 420 --page_height 297
|
||||
|
||||
And then converted to PNG:
|
||||
|
||||
inkscape fc4_pattern_A3.svg --export-type=png --export-dpi=300 \
|
||||
--export-background=white --export-background-opacity=1 \
|
||||
--export-filename=fc4_pattern_A3.png
|
||||
|
||||
Calibration image is split into b,g,r, and g is used as reference channel.
|
||||
The centres of each circle in red and blue channels are found as centres of ellipses
|
||||
and then calculated on a subpixel level. Each centre in red or blue channel is paired to
|
||||
a respective centre in green channel. Then, a polynomial model of degree 11 is fit onto the image,
|
||||
minimizing the difference between the displacements between centres in green and red/blue
|
||||
and the actual delta computed with polynomial coefficients. The coefficients are then saved in yaml
|
||||
format and can be used in this sample to correct images of the same camera, lens and settings.
|
||||
|
||||
usage:
|
||||
chromatic_calibration.py calibrate [-h] [--degree DEGREE] --coeffs_file YAML_FILE_PATH image [image ...]
|
||||
chromatic_calibration.py correct [-h] --coeffs_file YAML_FILE_PATH [-o OUTPUT] image
|
||||
chromatic_calibration.py full [-h] [--degree DEGREE] --coeffs_file YAML_FILE_PATH [-o OUTPUT] image
|
||||
|
||||
usage example:
|
||||
chromatic_calibration.py calibrate pattern_aberrated.png --coeffs_file calib_result.yaml
|
||||
|
||||
default values:
|
||||
--degree: 11
|
||||
-o, --output: corrected.png
|
||||
'''
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import yaml
|
||||
from scipy.optimize import minimize
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
|
||||
@dataclass
|
||||
class Polynomial2D:
|
||||
coeffs_x: np.ndarray
|
||||
coeffs_y: np.ndarray
|
||||
degree: int
|
||||
height: int
|
||||
width: int
|
||||
|
||||
def delta(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
mean_x, mean_y = self.width * 0.5, self.height * 0.5
|
||||
inv_std_x, inv_std_y = 1.0 / mean_x, 1.0 / mean_y
|
||||
x_n = (x - mean_x) * inv_std_x
|
||||
y_n = (y - mean_y) * inv_std_y
|
||||
terms = monomial_terms(x_n, y_n, self.degree)
|
||||
dx = terms @ self.coeffs_x
|
||||
dy = terms @ self.coeffs_y
|
||||
return dx.reshape(x.shape), dy.reshape(y.shape)
|
||||
|
||||
|
||||
|
||||
def validate_calibration_dict(data: dict) -> tuple[int, int, int]:
|
||||
required_keys = {
|
||||
"red_channel", "blue_channel", "image_width", "image_height"
|
||||
}
|
||||
missing = required_keys - data.keys()
|
||||
if missing:
|
||||
raise ValueError(f"Missing keys in YAML: {', '.join(missing)}")
|
||||
|
||||
width = int(data["image_width"])
|
||||
height = int(data["image_height"])
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Image width and height must be positive integers")
|
||||
|
||||
def _get_coeffs(channel: str, axis: str) -> np.ndarray:
|
||||
try:
|
||||
coeffs = np.asarray(data[channel][f"coeffs_{axis}"], dtype=float)
|
||||
except KeyError as e:
|
||||
raise ValueError(f"Missing {axis} coefficients for {channel}") from e
|
||||
if coeffs.ndim != 1:
|
||||
raise ValueError(f"{channel} {axis} coefficients must be a 1‑D list/array")
|
||||
if not np.all(np.isfinite(coeffs)):
|
||||
raise ValueError(f"{channel} {axis} coefficients contain NaN or Inf")
|
||||
return coeffs
|
||||
|
||||
rx = _get_coeffs("red_channel", "x")
|
||||
ry = _get_coeffs("red_channel", "y")
|
||||
bx = _get_coeffs("blue_channel", "x")
|
||||
by = _get_coeffs("blue_channel", "y")
|
||||
|
||||
for channel in ["red_channel", "blue_channel"]:
|
||||
try:
|
||||
rms = data[channel]["rms"]
|
||||
except KeyError as e:
|
||||
raise ValueError(f"Missing rms for {channel}") from e
|
||||
|
||||
for name, cx, cy in [("red", rx, ry), ("blue", bx, by)]:
|
||||
if cx.size != cy.size:
|
||||
raise ValueError(
|
||||
f"{name} channel: coeffs_x ({cx.size}) and coeffs_y "
|
||||
f"({cy.size}) lengths differ"
|
||||
)
|
||||
|
||||
if rx.size != bx.size:
|
||||
raise ValueError(
|
||||
f"Red and blue channels use different polynomial sizes "
|
||||
f"({rx.size} vs {bx.size})"
|
||||
)
|
||||
|
||||
m = rx.size
|
||||
n_float = (math.sqrt(1 + 8*m) - 3) / 2
|
||||
degree = int(round(n_float))
|
||||
expected_m = (degree + 1) * (degree + 2) // 2
|
||||
if expected_m != m:
|
||||
raise ValueError(
|
||||
f"Coefficient count {m} is not triangular (n != (deg+1)*(deg+2)/2); "
|
||||
f"nearest degree would be {degree} (needs {expected_m})"
|
||||
)
|
||||
|
||||
return degree, height, width
|
||||
|
||||
|
||||
def load_calib_result(path: str | None = None) -> dict[str, Any]:
|
||||
path = pathlib.Path(path)
|
||||
with path.open("r") as fh:
|
||||
if path.suffix.lower() in {".yaml", ".yml"}:
|
||||
data = yaml.safe_load(fh)
|
||||
else:
|
||||
raise ValueError("YAML file expected as input for the calibration result")
|
||||
|
||||
deg, height, width = validate_calibration_dict(data)
|
||||
|
||||
red_data = data["red_channel"]
|
||||
blue_data = data["blue_channel"]
|
||||
|
||||
poly_r = Polynomial2D(
|
||||
np.asarray(red_data["coeffs_x"]),
|
||||
np.asarray(red_data["coeffs_y"]),
|
||||
deg,
|
||||
height,
|
||||
width
|
||||
)
|
||||
poly_b = Polynomial2D(
|
||||
np.asarray(blue_data["coeffs_x"]),
|
||||
np.asarray(blue_data["coeffs_y"]),
|
||||
deg,
|
||||
height,
|
||||
width
|
||||
)
|
||||
|
||||
return {
|
||||
"poly_red": poly_r,
|
||||
"poly_blue": poly_b,
|
||||
"image_height": height,
|
||||
"image_width": width,
|
||||
}
|
||||
|
||||
|
||||
def repr_flow_seq(dumper, data):
|
||||
return dumper.represent_sequence('tag:yaml.org,2002:seq',
|
||||
data,
|
||||
flow_style=True)
|
||||
|
||||
|
||||
yaml.SafeDumper.add_representer(list, repr_flow_seq)
|
||||
|
||||
|
||||
def save_calib_result(calib, path: str | None = None) -> None:
|
||||
d = {
|
||||
"blue_channel": {
|
||||
"coeffs_x": calib["poly_blue"].coeffs_x.tolist(),
|
||||
"coeffs_y": calib["poly_blue"].coeffs_y.tolist(),
|
||||
"rms": calib["rms_red"]
|
||||
},
|
||||
"red_channel": {
|
||||
"coeffs_x": calib["poly_red"].coeffs_x.tolist(),
|
||||
"coeffs_y": calib["poly_red"].coeffs_y.tolist(),
|
||||
"rms": calib["rms_blue"]
|
||||
},
|
||||
"image_width": calib["image_width"],
|
||||
"image_height": calib["image_height"]
|
||||
}
|
||||
if path is not None:
|
||||
with open(path, "w") as fh:
|
||||
yaml.safe_dump(d,
|
||||
fh,
|
||||
version=(1, 2),
|
||||
default_flow_style=False,
|
||||
sort_keys=False)
|
||||
|
||||
|
||||
def monomial_terms(x: np.ndarray, y: np.ndarray, degree: int) -> np.ndarray:
|
||||
x = x.flatten()
|
||||
y = y.flatten()
|
||||
terms = []
|
||||
cnt = 0
|
||||
for total in range(degree + 1):
|
||||
for i in range(total + 1):
|
||||
j = total - i
|
||||
terms.append((x ** i) * (y ** j))
|
||||
cnt += 1
|
||||
return np.vstack(terms).T
|
||||
|
||||
|
||||
def detect_disk_centres(
|
||||
img: np.ndarray,
|
||||
*,
|
||||
min_area: int = 20,
|
||||
max_area: int | None = None,
|
||||
circularity_thresh: float = 0.7,
|
||||
morph_kernel: int = 3,
|
||||
) -> np.ndarray:
|
||||
if img.ndim != 2:
|
||||
raise ValueError("detect_disk_centres expects a grayscale image")
|
||||
blur = cv2.GaussianBlur(img, (5, 5), 0)
|
||||
_, mask = cv2.threshold(
|
||||
blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
|
||||
)
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (morph_kernel,) * 2)
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
|
||||
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
||||
|
||||
centres = []
|
||||
|
||||
for c in cnts:
|
||||
if len(c) < 5:
|
||||
continue
|
||||
area = cv2.contourArea(c)
|
||||
if area < min_area:
|
||||
continue
|
||||
if max_area is not None and area > max_area:
|
||||
continue
|
||||
|
||||
peri = cv2.arcLength(c, closed=True)
|
||||
circularity = 4 * np.pi * area / (peri * peri + 1e-12)
|
||||
if circularity < circularity_thresh:
|
||||
continue
|
||||
(cx, cy), (a, b), theta = cv2.fitEllipse(c)
|
||||
|
||||
eps = 1e-6
|
||||
pts = c.reshape(-1, 2).astype(np.float64)
|
||||
ct, st = np.cos(np.radians(theta)), np.sin(np.radians(theta))
|
||||
r = np.array([[ct, st], [-st, ct]])
|
||||
|
||||
# translate points so that they are centered around mean, and rotate them
|
||||
p = (r @ (pts.T - np.array([[cx], [cy]]))).T
|
||||
# ellipse equation
|
||||
f = (p[:, 0] / (a / 2 + eps)) ** 2 + (p[:, 1] / (b / 2 + eps)) ** 2 - 1
|
||||
# gradients of ellipse equation
|
||||
j = np.column_stack(
|
||||
[2 * p[:, 0] / ((a / 2 + eps) ** 2), 2 * p[:, 1] / ((b / 2 + eps) ** 2)]
|
||||
)
|
||||
|
||||
# solve least squares to get delta of centers
|
||||
delta, *_ = np.linalg.lstsq(j, -f, rcond=None)
|
||||
cx -= delta[0]
|
||||
cy -= delta[1]
|
||||
centres.append((cx, cy))
|
||||
|
||||
if len(centres) == 0:
|
||||
raise RuntimeError("No valid disks detected, check function parameters")
|
||||
|
||||
return np.asarray(centres, dtype=np.float32)
|
||||
|
||||
|
||||
def pair_keypoints(
|
||||
ref: np.ndarray,
|
||||
target: np.ndarray,
|
||||
max_error: float = 30.0,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
tree = cKDTree(ref)
|
||||
dists, idx = tree.query(target, distance_upper_bound=max_error)
|
||||
mask = np.isfinite(dists)
|
||||
if not np.any(mask):
|
||||
raise RuntimeError("No valid keypoint matches were created")
|
||||
target_valid = target[mask]
|
||||
ref_valid = ref[idx[mask]]
|
||||
disp = ref_valid - target_valid
|
||||
return target_valid[:, 0], target_valid[:, 1], disp
|
||||
|
||||
|
||||
def fit_channel(
|
||||
x: np.ndarray,
|
||||
y: np.ndarray,
|
||||
disp: np.ndarray,
|
||||
degree: int,
|
||||
height: int,
|
||||
width: int,
|
||||
method: str = "L-BFGS-B",
|
||||
) -> tuple[np.ndarray, np.ndarray, float]:
|
||||
mean_x, mean_y = width * 0.5, height * 0.5
|
||||
inv_std_x, inv_std_y = 1.0 / mean_x, 1.0 / mean_y
|
||||
x = (x - mean_x) * inv_std_x
|
||||
y = (y - mean_y) * inv_std_y
|
||||
|
||||
terms = monomial_terms(x, y, degree)
|
||||
m = terms.shape[1]
|
||||
|
||||
def objective(c: np.ndarray) -> float:
|
||||
cx = c[:m]
|
||||
cy = c[m:]
|
||||
pred_x = terms @ cx
|
||||
pred_y = terms @ cy
|
||||
err = np.hstack([pred_x - disp[:, 0], pred_y - disp[:, 1]])
|
||||
if np.any(np.isnan(err)) or np.any(np.isinf(err)):
|
||||
return 1e12
|
||||
return np.sum(err ** 2)
|
||||
|
||||
cx_ls, *_ = np.linalg.lstsq(terms, disp[:, 0], rcond=None)
|
||||
cy_ls, *_ = np.linalg.lstsq(terms, disp[:, 1], rcond=None)
|
||||
c0 = np.hstack([cx_ls, cy_ls])
|
||||
|
||||
res = minimize(objective, c0, method=method, options={
|
||||
"maxiter": 500,
|
||||
"maxfun": 5000,
|
||||
"maxls": 50,
|
||||
"ftol": 1e-9,
|
||||
})
|
||||
|
||||
coeffs_x = res.x[:m]
|
||||
coeffs_y = res.x[m:]
|
||||
rms = math.sqrt(res.fun / disp.shape[0])
|
||||
return coeffs_x, coeffs_y, rms
|
||||
|
||||
|
||||
def fit_polynomials(
|
||||
x_r: np.ndarray,
|
||||
y_r: np.ndarray,
|
||||
disp_r: np.ndarray,
|
||||
x_b: np.ndarray,
|
||||
y_b: np.ndarray,
|
||||
disp_b: np.ndarray,
|
||||
degree: int,
|
||||
height: int,
|
||||
width: int
|
||||
) -> tuple[Polynomial2D, Polynomial2D, float, float]:
|
||||
crx, cry, rms_r = fit_channel(x_r, y_r, disp_r, degree, height, width)
|
||||
cbx, cby, rms_b = fit_channel(x_b, y_b, disp_b, degree, height, width)
|
||||
poly_r = Polynomial2D(crx, cry, degree, height, width)
|
||||
poly_b = Polynomial2D(cbx, cby, degree, height, width)
|
||||
return poly_r, poly_b, rms_r, rms_b
|
||||
|
||||
def calibrate(
|
||||
imgs: list[np.ndarray],
|
||||
degree: int = 11,
|
||||
):
|
||||
xr_all, yr_all, dr_all = [], [], []
|
||||
xb_all, yb_all, db_all = [], [], []
|
||||
h0, w0 = None, None
|
||||
|
||||
for i, img in enumerate(imgs):
|
||||
if img is None or img.ndim != 3 or img.shape[2] != 3:
|
||||
raise ValueError("Expected a BGR color image")
|
||||
|
||||
h, w = img.shape[:2]
|
||||
b, g, r = cv2.split(img)
|
||||
|
||||
pts_g = detect_disk_centres(g)
|
||||
pts_r = detect_disk_centres(r)
|
||||
pts_b = detect_disk_centres(b)
|
||||
|
||||
xr, yr, disp_r = pair_keypoints(pts_g, pts_r)
|
||||
xb, yb, disp_b = pair_keypoints(pts_g, pts_b)
|
||||
if h0 is None:
|
||||
h0, w0 = h, w
|
||||
else:
|
||||
if (h, w) != (h0, w0):
|
||||
raise ValueError(
|
||||
f"All calibration images must have the same resolution; "
|
||||
f"got {(h,w)} vs {(h0,w0)} at image #{i}"
|
||||
)
|
||||
|
||||
xr_all.append(xr)
|
||||
yr_all.append(yr)
|
||||
dr_all.append(disp_r)
|
||||
xb_all.append(xb)
|
||||
yb_all.append(yb)
|
||||
db_all.append(disp_b)
|
||||
|
||||
xr = np.concatenate(xr_all, axis=0)
|
||||
yr = np.concatenate(yr_all, axis=0)
|
||||
disp_r = np.concatenate(dr_all, axis=0)
|
||||
|
||||
xb = np.concatenate(xb_all, axis=0)
|
||||
yb = np.concatenate(yb_all, axis=0)
|
||||
disp_b = np.concatenate(db_all, axis=0)
|
||||
|
||||
poly_r, poly_b, rms_r, rms_b = fit_polynomials(
|
||||
xr, yr, disp_r,
|
||||
xb, yb, disp_b,
|
||||
degree, h0, w0
|
||||
)
|
||||
|
||||
print(f"Calibrated polynomial with degree {degree} on {len(imgs)} images, "
|
||||
f"RMS red: {rms_r:.3f} px; RMS blue: {rms_b:.3f} px")
|
||||
|
||||
return {
|
||||
"poly_red": poly_r,
|
||||
"poly_blue": poly_b,
|
||||
"image_width": w0,
|
||||
"image_height": h0,
|
||||
"rms_red": rms_r,
|
||||
"rms_blue": rms_b,
|
||||
}
|
||||
|
||||
def calibrate_multi_degree(
|
||||
imgs: list[np.ndarray],
|
||||
k0: int,
|
||||
k1: int,
|
||||
) -> dict[int, tuple[Polynomial2D, Polynomial2D, float, float]]:
|
||||
"""
|
||||
Returns a dict mapping degree → (poly_r, poly_b, rms_r, rms_b).
|
||||
"""
|
||||
xr_all, yr_all, dr_all = [], [], []
|
||||
xb_all, yb_all, db_all = [], [], []
|
||||
h0, w0 = None, None
|
||||
|
||||
for i, img in enumerate(imgs):
|
||||
if img is None or img.ndim != 3 or img.shape[2] != 3:
|
||||
raise ValueError("Expected a BGR color image")
|
||||
|
||||
h, w = img.shape[:2]
|
||||
b, g, r = cv2.split(img)
|
||||
|
||||
pts_g = detect_disk_centres(g)
|
||||
pts_r = detect_disk_centres(r)
|
||||
pts_b = detect_disk_centres(b)
|
||||
|
||||
xr, yr, disp_r = pair_keypoints(pts_g, pts_r)
|
||||
xb, yb, disp_b = pair_keypoints(pts_g, pts_b)
|
||||
if h0 is None:
|
||||
h0, w0 = h, w
|
||||
else:
|
||||
if (h, w) != (h0, w0):
|
||||
raise ValueError(
|
||||
f"All calibration images must have the same resolution; "
|
||||
f"got {(h,w)} vs {(h0,w0)} at image #{i}"
|
||||
)
|
||||
|
||||
xr_all.append(xr)
|
||||
yr_all.append(yr)
|
||||
dr_all.append(disp_r)
|
||||
xb_all.append(xb)
|
||||
yb_all.append(yb)
|
||||
db_all.append(disp_b)
|
||||
|
||||
xr = np.concatenate(xr_all, axis=0)
|
||||
yr = np.concatenate(yr_all, axis=0)
|
||||
disp_r = np.concatenate(dr_all, axis=0)
|
||||
|
||||
xb = np.concatenate(xb_all, axis=0)
|
||||
yb = np.concatenate(yb_all, axis=0)
|
||||
disp_b = np.concatenate(db_all, axis=0)
|
||||
|
||||
results = {}
|
||||
for deg in range(k0, k1+1):
|
||||
print(deg)
|
||||
|
||||
poly_r, poly_b, rms_r, rms_b = fit_polynomials(
|
||||
xr,
|
||||
yr,
|
||||
disp_r,
|
||||
xb,
|
||||
yb,
|
||||
disp_b,
|
||||
deg,
|
||||
h0,
|
||||
w0
|
||||
)
|
||||
print(f"Calibrated polynomial with degree {deg}, RMS red: {rms_r:.3f} px; RMS blue: {rms_b:.3f} px")
|
||||
results[deg] = (poly_r, poly_b, rms_r, rms_b)
|
||||
return results
|
||||
|
||||
|
||||
def build_remap(
|
||||
h: int,
|
||||
w: int,
|
||||
poly: Polynomial2D,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
x, y = np.meshgrid(np.arange(w, dtype=np.float32), np.arange(h, dtype=np.float32))
|
||||
dx, dy = poly.delta(x, y)
|
||||
map_x = (x - dx).astype(np.float32)
|
||||
map_y = (y - dy).astype(np.float32)
|
||||
return map_x, map_y
|
||||
|
||||
|
||||
def correct_image(
|
||||
img: np.ndarray,
|
||||
calib: dict[str, Any],
|
||||
) -> np.ndarray:
|
||||
if img.ndim != 3 or img.shape[2] != 3:
|
||||
raise ValueError("correct_image expects a BGR colour image")
|
||||
|
||||
h, w = img.shape[:2]
|
||||
b, g, r = cv2.split(img)
|
||||
map_x_r, map_y_r = build_remap(h, w, calib["poly_red"])
|
||||
map_x_b, map_y_b = build_remap(h, w, calib["poly_blue"])
|
||||
|
||||
r_corr = cv2.remap(r, map_x_r, map_y_r, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
||||
b_corr = cv2.remap(b, map_x_b, map_y_b, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
||||
|
||||
map_x_g, map_y_g = np.meshgrid(
|
||||
np.arange(w, dtype=np.float32),
|
||||
np.arange(h, dtype=np.float32)
|
||||
)
|
||||
|
||||
g_corr = cv2.remap(g, map_x_g, map_y_g,
|
||||
cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_REPLICATE)
|
||||
|
||||
corrected = cv2.merge((b_corr, g_corr, r_corr))
|
||||
return corrected
|
||||
|
||||
def detect_disk_contours(
|
||||
img: np.ndarray,
|
||||
*,
|
||||
min_area: int = 20,
|
||||
max_area: int | None = None,
|
||||
circularity_thresh: float = 0.7,
|
||||
morph_kernel: int = 3,
|
||||
) -> list[np.ndarray]:
|
||||
"""
|
||||
Find all external contours of “discs” in a binary mask of `img` and return
|
||||
their raw point coordinates as a list of (N_i,2) float32 arrays.
|
||||
"""
|
||||
if img.ndim != 2:
|
||||
raise ValueError("detect_disk_contours expects a grayscale image")
|
||||
blur = cv2.GaussianBlur(img, (5, 5), 0)
|
||||
_, mask = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (morph_kernel,)*2)
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
|
||||
|
||||
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
||||
contours = []
|
||||
for c in cnts:
|
||||
if len(c) < 5:
|
||||
continue
|
||||
area = cv2.contourArea(c)
|
||||
if area < min_area or (max_area is not None and area > max_area):
|
||||
continue
|
||||
peri = cv2.arcLength(c, True)
|
||||
circ = 4 * math.pi * area / (peri*peri + 1e-12)
|
||||
if circ < circularity_thresh:
|
||||
continue
|
||||
pts = c.reshape(-1, 2).astype(np.float32)
|
||||
contours.append(pts)
|
||||
if not contours:
|
||||
raise RuntimeError("No valid disk contours found")
|
||||
return contours
|
||||
|
||||
def warp_and_compare(contours_src: list[np.ndarray],
|
||||
poly_src: Polynomial2D,
|
||||
pts_ref: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Warp src-channel contours through poly_src.delta,
|
||||
then compute for each warped point its distance to the nearest
|
||||
green contour point in pts_ref.
|
||||
"""
|
||||
pts = np.vstack(contours_src)
|
||||
xs, ys = pts[:,0], pts[:,1]
|
||||
dx, dy = poly_src.delta(xs, ys)
|
||||
warped = np.column_stack([xs - dx, ys - dy])
|
||||
|
||||
tree = cKDTree(pts_ref)
|
||||
dists, _ = tree.query(warped, k=1)
|
||||
return dists
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Chromatic aberration calibration and correction tool",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sc = sub.add_parser("calibrate", help="Calibrate from calibration target image")
|
||||
sc.add_argument("image", nargs="+", help="One or more images of black‑disk calibration target")
|
||||
sc.add_argument("--degree", type=int, default=11, help="Polynomial degree")
|
||||
sc.add_argument("--coeffs_file", required=True, help="Save coefficients to YAML file")
|
||||
|
||||
sr = sub.add_parser("correct", help="Correct a photograph using saved coefficients")
|
||||
sr.add_argument("image", help="Input image to be corrected")
|
||||
sr.add_argument("--coeffs_file", required=True,
|
||||
help="Calibration coefficient file (.json/.yaml)")
|
||||
sr.add_argument("-o", "--output", default="corrected.png", help="Output filename")
|
||||
|
||||
sf = sub.add_parser("full",help="Calibrate from calibration target image and \
|
||||
correct the calibration target")
|
||||
sf.add_argument("image", nargs="+", help="One or more images of black‑disk calibration target")
|
||||
sf.add_argument("--degree", type=int, default=11, help="Polynomial degree")
|
||||
sf.add_argument("--coeffs_file", required=True, help="Save coefficients to YAML file")
|
||||
sf.add_argument("-o", "--output", default="corrected.png", help="Output filename")
|
||||
|
||||
ss = sub.add_parser("scan", help="Sweep degree range and report errors")
|
||||
ss.add_argument("image", nargs="+", help="Calibration image path")
|
||||
ss.add_argument("--degree_range", nargs=2, type=int, metavar=("k0","k1"),
|
||||
required=True, help="Inclusive degree range to scan")
|
||||
ss.add_argument("--method", default="POWELL", help="Optimizer method")
|
||||
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def cmd_calibrate(parsed_args: argparse.Namespace) -> None:
|
||||
paths = parsed_args.image if isinstance(parsed_args.image, list) else [parsed_args.image]
|
||||
imgs = []
|
||||
for p in paths:
|
||||
im = cv2.imread(p, cv2.IMREAD_COLOR)
|
||||
if im is None:
|
||||
raise FileNotFoundError(p)
|
||||
imgs.append(im)
|
||||
|
||||
calib = calibrate(imgs, degree=parsed_args.degree)
|
||||
save_calib_result(calib, path=parsed_args.coeffs_file)
|
||||
print("Saved coefficients to", parsed_args.coeffs_file)
|
||||
|
||||
|
||||
def cmd_correct(parsed_args: argparse.Namespace) -> None:
|
||||
path = parsed_args.image
|
||||
|
||||
fs = cv2.FileStorage(parsed_args.coeffs_file, cv2.FileStorage_READ)
|
||||
if not fs.isOpened():
|
||||
print(f"Could not calibration coefficients from {parsed_args.coeffs_file}")
|
||||
return
|
||||
coeff_mat, calib_size, degree = cv2.loadChromaticAberrationParams(fs.root())
|
||||
|
||||
img = cv2.imread(path, cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
print(f"Could not read image {path}")
|
||||
return
|
||||
|
||||
fixed = cv2.correctChromaticAberration(img, coeff_mat, calib_size, degree)
|
||||
|
||||
cv2.imwrite(parsed_args.output, fixed)
|
||||
print(f"Corrected image written to {parsed_args.output}")
|
||||
|
||||
|
||||
def cmd_full(parsed_args: argparse.Namespace) -> None:
|
||||
paths = parsed_args.image if isinstance(parsed_args.image, list) else [parsed_args.image]
|
||||
imgs = []
|
||||
for p in paths:
|
||||
im = cv2.imread(p, cv2.IMREAD_COLOR)
|
||||
if im is None:
|
||||
raise FileNotFoundError(p)
|
||||
imgs.append(im)
|
||||
|
||||
calib = calibrate(imgs, degree=parsed_args.degree)
|
||||
img_for_correction = imgs[0]
|
||||
save_calib_result(calib, path=parsed_args.coeffs_file)
|
||||
print("Saved coefficients to", parsed_args.coeffs_file)
|
||||
|
||||
fs = cv2.FileStorage(parsed_args.coeffs_file, cv2.FileStorage_READ)
|
||||
if not fs.isOpened():
|
||||
print(f"Could not calibration coefficients from {parsed_args.coeffs_file}")
|
||||
return
|
||||
coeff_mat, calib_size, degree = cv2.loadChromaticAberrationParams(fs.root())
|
||||
|
||||
fixed = cv2.correctChromaticAberration(img_for_correction, coeff_mat, calib_size, degree)
|
||||
cv2.imwrite(parsed_args.output, fixed)
|
||||
print(f"Corrected image written to {parsed_args.output}")
|
||||
|
||||
|
||||
def cmd_scan(parsed_args: argparse.Namespace) -> None:
|
||||
paths = parsed_args.image if isinstance(parsed_args.image, list) else [parsed_args.image]
|
||||
imgs = []
|
||||
for p in paths:
|
||||
im = cv2.imread(p, cv2.IMREAD_COLOR)
|
||||
if im is None:
|
||||
raise FileNotFoundError(p)
|
||||
imgs.append(im)
|
||||
|
||||
k0, k1 = parsed_args.degree_range
|
||||
results = calibrate_multi_degree(imgs, k0, k1)
|
||||
|
||||
all_contours_b = []
|
||||
all_contours_g = []
|
||||
all_contours_r = []
|
||||
|
||||
for img in imgs:
|
||||
b, g, r = cv2.split(img)
|
||||
all_contours_b.extend(detect_disk_contours(b))
|
||||
all_contours_g.extend(detect_disk_contours(g))
|
||||
all_contours_r.extend(detect_disk_contours(r))
|
||||
|
||||
pts_g = np.vstack(all_contours_g)
|
||||
|
||||
print(f"Reference degree: {k1}\n")
|
||||
header = "deg | max_r mean_r std_r | max_b mean_b std_b"
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
|
||||
for deg in sorted(results):
|
||||
if deg == k1:
|
||||
continue
|
||||
pr, pb, _, _ = results[deg]
|
||||
|
||||
d_r = warp_and_compare(all_contours_r, pr, pts_g)
|
||||
d_b = warp_and_compare(all_contours_b, pb, pts_g)
|
||||
|
||||
s = {
|
||||
'max_r': d_r.max(), 'mean_r': d_r.mean(), 'std_r': d_r.std(),
|
||||
'max_b': d_b.max(), 'mean_b': d_b.mean(), 'std_b': d_b.std()
|
||||
}
|
||||
|
||||
print(f"{deg:3d} | "
|
||||
f"{s['max_r']:8.3f} {s['mean_r']:8.3f} {s['std_r']:8.3f} | "
|
||||
f"{s['max_b']:8.3f} {s['mean_b']:8.3f} {s['std_b']:8.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
if args.cmd == "calibrate":
|
||||
cmd_calibrate(args)
|
||||
elif args.cmd == "correct":
|
||||
cmd_correct(args)
|
||||
elif args.cmd == "full":
|
||||
cmd_full(args)
|
||||
elif args.cmd == "scan":
|
||||
cmd_scan(args)
|
||||
Reference in New Issue
Block a user