mirror-ppr / mirrorppr /data /llw_face_editor.py
multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
834d1cf verified
Raw
History Blame Contribute Delete
25.8 kB
from __future__ import annotations
from pathlib import Path
from typing import Iterable, Mapping, Sequence
import cv2
import numpy as np
import os
Operation = Mapping[str, object]
class SimilarityMLS:
def __init__(self, grid_size=50, alpha=1.0):
self.grid_size = grid_size
self.alpha = alpha
def _calculate_weights(self, grid_pts, ctrl_pts):
n_grid = grid_pts.shape[0]
n_ctrl = ctrl_pts.shape[0]
grid_pts_exp = np.tile(grid_pts[:, np.newaxis, :], (1, n_ctrl, 1))
ctrl_pts_exp = np.tile(ctrl_pts[np.newaxis, :, :], (n_grid, 1, 1))
d2 = np.sum((grid_pts_exp - ctrl_pts_exp) ** 2, axis=2) + 1e-8
return 1.0 / (d2 ** self.alpha)
def warp(self, img, src_pts, dst_pts):
h, w = img.shape[:2]
grid_x = np.linspace(0, w, w // self.grid_size + 1)
grid_y = np.linspace(0, h, h // self.grid_size + 1)
grid_x, grid_y = np.meshgrid(grid_x, grid_y)
grid_pts = np.vstack([grid_x.ravel(), grid_y.ravel()]).T
n_grid = grid_pts.shape[0]
weights = self._calculate_weights(grid_pts, src_pts)
total_weights = np.sum(weights, axis=1, keepdims=True)
p_star = (weights @ src_pts) / total_weights
q_star = (weights @ dst_pts) / total_weights
p_hat = src_pts[np.newaxis, :, :] - p_star[:, np.newaxis, :]
q_hat = dst_pts[np.newaxis, :, :] - q_star[:, np.newaxis, :]
mu = np.sum(weights[:, :, np.newaxis] * p_hat ** 2, axis=(1, 2), keepdims=True)
v_hat = grid_pts - p_star
p_hat_perp = np.stack([-p_hat[:, :, 1], p_hat[:, :, 0]], axis=2)
vp_dot_phat = np.sum(v_hat[:, np.newaxis, :] * p_hat, axis=2)
vp_dot_phat_perp = np.sum(v_hat[:, np.newaxis, :] * p_hat_perp, axis=2)
T1 = vp_dot_phat[:, :, np.newaxis] * q_hat
T2 = vp_dot_phat_perp[:, :, np.newaxis] * np.stack([-q_hat[:, :, 1], q_hat[:, :, 0]], axis=2)
weighted_sum = np.sum(weights[:, :, np.newaxis] * (T1 + T2), axis=1)
result_grid = q_star + weighted_sum / mu.squeeze(-1)
map_x = cv2.resize(result_grid[:, 0].reshape(grid_x.shape).astype(np.float32), (w, h))
map_y = cv2.resize(result_grid[:, 1].reshape(grid_y.shape).astype(np.float32), (w, h))
return cv2.remap(img, map_x, map_y, interpolation=cv2.INTER_LINEAR)
class FaceEditor:
def __init__(self):
try:
import mediapipe as mp
except ImportError as exc:
raise ImportError("LLW face retouching requires mediapipe. Install the mirrorppr data extra or run the provided environment setup.") from exc
self.mp_face_mesh = mp.solutions.face_mesh
self.face_mesh = self.mp_face_mesh.FaceMesh(
static_image_mode=True,
max_num_faces=1,
refine_landmarks=True,
min_detection_confidence=0.5
)
def _get_landmarks(self, image):
h, w = image.shape[:2]
results = self.face_mesh.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
if not results.multi_face_landmarks:
return None
landmarks = results.multi_face_landmarks[0].landmark
points = np.array([(int(l.x * w), int(l.y * h)) for l in landmarks])
return points
def _create_roi_mask(self, h, w, points, regions_indices, blur_ratio):
"""
regions_indices: List[List[int]], 例如 [[左眼索引...], [右眼索引...]]
"""
mask = np.zeros((h, w), dtype=np.uint8)
for indices in regions_indices:
if not indices:
continue
roi_points = points[indices].astype(np.int32)
hull = cv2.convexHull(roi_points)
cv2.fillConvexPoly(mask, hull, 255)
blur_k = int(min(h, w) * blur_ratio)
if blur_k % 2 == 0:
blur_k += 1
mask = cv2.GaussianBlur(mask, (blur_k, blur_k), 0)
return mask.astype(np.float32) / 255.0
def _get_default_params(self, op_type):
"""
获取操作的默认参数,方便在 process_batch 中合并
"""
defaults = {
'grid_size': 30,
'alpha': 1.0,
'blur_ratio': 0.05
}
return defaults
def _get_operation_config(self, op_type, landmarks, strength, params):
"""
根据操作类型分发配置逻辑
"""
if op_type == 'eye_resize':
return self._config_eye_resize(landmarks, strength, params)
elif op_type == 'eye_distance':
return self._config_eye_distance(landmarks, strength, params)
elif op_type == 'nose_length':
return self._config_nose_length(landmarks, strength, params)
elif op_type == 'nose_alar':
return self._config_nose_alar(landmarks, strength, params)
elif op_type == 'mouth_position':
return self._config_mouth_position(landmarks, strength, params)
elif op_type == 'lip_thickness':
return self._config_lip_thickness(landmarks, strength, params)
elif op_type == 'mouth_resize':
return self._config_mouth_resize(landmarks, strength, params)
elif op_type == 'nose_bridge':
return self._config_nose_bridge(landmarks, strength, params)
else:
raise ValueError(f"Unknown operation: {op_type}")
def _config_nose_length(self, landmarks, strength, params):
"""
鼻子变短/变长配置
Strength < 0: 变短 (Shorten) - 鼻头上移,人中变长
Strength > 0: 变长 (Lengthen) - 鼻头下移,人中变短
"""
moving_indices = [
1, 4, 19,
279, 49,
2, 98, 327, 456, 236,
94
]
anchor_idx = [
168, 6, 197, 195,
33, 133, 362, 263,
185,40,39,37,0,267,269,270,409,
116, 123, 345, 352
]
mask_idx_nose_long = [47, 128, 142, 164, 165, 167, 168, 193, 203, 244, 277, 357, 371, 391, 393, 417, 423, 464]
mask_groups = [mask_idx_nose_long]
src_pts = []
dst_pts = []
nose_vec_ref = landmarks[1] - landmarks[168]
nose_length = np.linalg.norm(nose_vec_ref)
if strength >= 0:
factor = params.get('max_nose_len_widen_ratio', 0.15)
else:
factor = params.get('max_nose_len_shorten_ratio', 0.15)
move_dist = nose_length * (abs(strength) / 100.0) * factor
vec_axis = (landmarks[1] - landmarks[168]).astype(np.float32)
norm_axis = np.linalg.norm(vec_axis)
if norm_axis > 0: vec_axis /= norm_axis
if strength >= 0:
final_vec = vec_axis * move_dist
else:
final_vec = -vec_axis * move_dist
for idx in moving_indices:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt + final_vec)
for idx in anchor_idx:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt)
return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups
def _config_nose_alar(self, landmarks, strength, params):
"""
鼻翼变窄/变宽配置
Strength < 0: 变窄 (Narrow) - 鼻翼向内收
Strength > 0: 变宽 (Widen)
"""
left_alar_indices = [49, 102, 64, 218, 129]
right_alar_indices = [279, 331, 294, 438, 358]
anchor_idx = [
1, 2, 94, 19,
168, 6, 197, 195, 4,
0, 37, 267,
205, 50, 123, 116,
425, 280, 352, 345
]
mask_idx_alar = [
195, 4,
279, 425, 331, 294, 327,
2, 94,
98, 64, 102, 205, 49
]
mask_groups = [mask_idx_alar]
src_pts = []
dst_pts = []
alar_width = np.linalg.norm(landmarks[331] - landmarks[102])
if strength >= 0:
factor = params.get('max_alar_widen_ratio', 0.25)
scale = 1.0 + (strength / 100.0) * factor
else:
factor = params.get('max_alar_narrow_ratio', 0.25)
scale = 1.0 - (abs(strength) / 100.0) * factor
vec_center_line = (landmarks[2] - landmarks[168]).astype(np.float32)
norm_cl = np.linalg.norm(vec_center_line)
if norm_cl > 0: vec_center_line /= norm_cl
def get_projection_point(pt, line_start, line_vec):
vec_ap = (pt - line_start).astype(np.float32)
proj_len = np.dot(vec_ap, line_vec)
return line_start + line_vec * proj_len
all_moving_indices = left_alar_indices + right_alar_indices
for idx in all_moving_indices:
pt = landmarks[idx]
src_pts.append(pt)
proj_pt = get_projection_point(pt, landmarks[168], vec_center_line)
vec_radial = pt - proj_pt
dst_pts.append(proj_pt + vec_radial * scale)
for idx in anchor_idx:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt)
return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups
def _config_eye_resize(self, landmarks, strength, params):
"""
眼睛放大/缩小的具体配置
"""
left_eye_idx = [249, 263, 362, 373, 374, 380, 381, 382, 384, 385, 386, 387, 388, 390, 398, 466]
right_eye_idx = [7, 33, 133, 144, 145, 153, 154, 155, 157, 158, 159, 160, 161, 163, 173, 246]
anchor_idx = [1, 168, 197, 6, 195, 4, 226, 446, 152, 10, 50, 280]
mask_idx_right =[22, 23, 24, 25, 26, 27, 28, 29, 30, 56, 110, 112, 130, 190, 243, 247]
mask_idx_left = [252, 253, 254, 255, 256, 257, 258, 259, 260, 286, 339, 341, 359, 414, 463, 467]
mask_groups = [mask_idx_right, mask_idx_left]
src_pts = []
dst_pts = []
if strength >= 0:
factor = params.get('max_enlarge', 0.25)
scale = 1.0 + (strength / 100.0) * factor
else:
factor = params.get('max_shrink', 0.20)
scale = 1.0 + (strength / 100.0) * factor
def process_region(indices):
pts = landmarks[indices]
center = np.mean(pts, axis=0)
for pt in pts:
src_pts.append(pt)
vec = pt - center
dst_pts.append(center + vec * scale)
process_region(left_eye_idx)
process_region(right_eye_idx)
for idx in anchor_idx:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt)
return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups
def _config_eye_distance(self, landmarks, strength, params):
"""
眼距调整 (修正版:只平移,不缩放)
Strength > 0: 眼距变宽
Strength < 0: 眼距变窄
"""
right_moving_idx = [22, 23, 24, 25, 26, 27, 28, 29, 30, 56, 110, 112, 130, 190, 243, 247]
left_moving_idx = [252, 253, 254, 255, 256, 257, 258, 259, 260, 286, 339, 341, 359, 414, 463, 467]
anchor_idx = [
1, 2, 98, 327,
10, 152,
234, 454,
13, 14, 78, 308
]
mask_idx_right = [31, 113, 189, 221, 222, 223, 224, 225, 226, 228, 229, 230, 231, 232, 233, 244]
mask_idx_left=[261, 342, 413, 441, 442, 443, 444, 445, 446, 448, 449, 450, 451, 452, 453, 464]
mask_groups = [mask_idx_right, mask_idx_left]
src_pts = []
dst_pts = []
center_idx = 168
center_pt = landmarks[center_idx]
if strength >= 0:
factor = params.get('max_dist_widen', 0.15)
scale = 1.0 + (strength / 100.0) * factor
else:
factor = params.get('max_dist_narrow', 0.15)
scale = 1.0 - (abs(strength) / 100.0) * factor
r_pts = landmarks[right_moving_idx]
r_centroid = np.mean(r_pts, axis=0)
r_vec = r_centroid - center_pt
r_centroid_new = center_pt + r_vec * scale
r_translation = r_centroid_new - r_centroid
for idx in right_moving_idx:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt + r_translation)
l_pts = landmarks[left_moving_idx]
l_centroid = np.mean(l_pts, axis=0)
l_vec = l_centroid - center_pt
l_centroid_new = center_pt + l_vec * scale
l_translation = l_centroid_new - l_centroid
for idx in left_moving_idx:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt + l_translation)
for idx in anchor_idx:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt)
src_pts.append(center_pt)
dst_pts.append(center_pt)
return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups
def _config_mouth_position(self, landmarks, strength, params):
"""
嘴巴上下移动配置 (基于解剖学距离限制)
Strength < 0: 上移 (最大幅度参照 人中长度)
Strength > 0: 下移 (最大幅度参照 下唇窝距离)
"""
lips_indices = [0, 13, 14, 17, 37, 39, 40, 61, 78, 80, 81, 82, 84, 87, 88, 91, 95, 146, 178, 181, 185, 191, 267, 269, 270, 291, 308, 310, 311, 312, 314, 317, 318, 321, 324, 375, 402, 405, 409, 415]
anchor_idx = [
2, 98, 327, 94, 19, 1,
152, 377, 148, 365, 136,
234, 454, 58, 288, 361, 132, 93, 323, 45, 275
]
mask_idx_mouth_zone = [18, 43, 57, 83, 92, 106, 164, 165, 167, 182, 186, 273, 287, 313, 322, 335, 391, 393, 406, 410]
mask_groups = [mask_idx_mouth_zone]
src_pts = []
dst_pts = []
pt_0 = landmarks[0]
pt_164 = landmarks[164]
pt_17 = landmarks[17]
pt_18 = landmarks[18]
dist_up_limit = float(abs(pt_0[1] - pt_164[1]))
dist_down_limit = float(abs(pt_17[1] - pt_18[1]))
move_y = 0.0
if strength < 0:
factor = params.get('limit_mouth_up_factor', 0.8)
move_dist = dist_up_limit * (abs(strength) / 100.0) * factor
move_y = -move_dist
else:
factor = params.get('limit_mouth_down_factor', 0.8)
move_dist = dist_down_limit * (strength / 100.0) * factor
move_y = move_dist
translation = np.array([0, move_y], dtype=np.float32)
for idx in lips_indices:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt + translation)
for idx in anchor_idx:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt)
return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups
def _config_lip_thickness(self, landmarks, strength, params):
"""
嘴唇变厚/变薄配置 (严格解剖学版:上下唇独立参照各自厚度)
"""
upper_lip_outer_moving = [185,40,39,37,0,267,269,270,409]
lower_lip_outer_moving = [146, 91, 181, 84, 17, 314, 405, 321, 375]
anchor_idx = [191, 80, 81, 82, 13, 312, 311, 310, 415, 14, 87, 88, 95, 178, 317, 318, 324, 402, 61, 291, 2, 98, 327, 18, 200, 234, 454, 58, 288, 361, 132, 93, 323]
mask_idx_expanded =[18, 43, 57, 83, 92, 106, 164, 165, 167, 182, 186, 273, 287, 313, 322, 335, 391, 393, 406, 410]
mask_groups = [mask_idx_expanded]
src_pts = []
dst_pts = []
upper_thickness = np.linalg.norm(landmarks[13] - landmarks[0])
lower_thickness = np.linalg.norm(landmarks[14] - landmarks[17])
thickness = max(upper_thickness,lower_thickness)
if strength >= 0:
factor = params.get('max_lip_thicken_ratio', 0.4)
else:
factor = params.get('max_lip_thin_ratio', 0.3)
move_dist_upper = thickness * (abs(strength) / 100.0) * factor
move_dist_lower = thickness * (abs(strength) / 100.0) * factor
vec_up = (landmarks[2] - landmarks[13]).astype(np.float32)
norm_up = np.linalg.norm(vec_up)
if norm_up > 0: vec_up /= norm_up
vec_down = (landmarks[152] - landmarks[14]).astype(np.float32)
norm_down = np.linalg.norm(vec_down)
if norm_down > 0: vec_down /= norm_down
if strength >= 0:
final_vec_upper = vec_up * move_dist_upper
final_vec_lower = vec_down * move_dist_lower
else:
final_vec_upper = -vec_up * move_dist_upper
final_vec_lower = -vec_down * move_dist_lower
for idx in upper_lip_outer_moving:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt + final_vec_upper)
for idx in lower_lip_outer_moving:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt + final_vec_lower)
for idx in anchor_idx:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt)
return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups
def _config_mouth_resize(self, landmarks, strength, params):
"""
嘴巴整体缩放配置
Strength > 0: 变大 (Enlarge)
Strength < 0: 变小 (Shrink)
"""
lips_indices = [0, 13, 14, 17, 37, 39, 40, 61, 78, 80, 81, 82, 84, 87, 88, 91, 95, 146, 178, 181, 185, 191, 267, 269, 270, 291, 308, 310, 311, 312, 314, 317, 318, 321, 324, 375, 402, 405, 409, 415]
anchor_idx = [
2, 98, 327,
205, 425,
152, 377, 148,
234, 454, 58, 288, 361, 132, 93, 323,
164
]
mask_idx_expanded = [18, 43, 57, 83, 92, 106, 164, 165, 167, 182, 186, 273, 287, 313, 322, 335, 391, 393, 406, 410]
mask_groups = [mask_idx_expanded]
src_pts = []
dst_pts = []
mouth_pts = landmarks[lips_indices]
center = np.mean(mouth_pts, axis=0)
if strength >= 0:
factor = params.get('max_mouth_enlarge', 0.25)
scale = 1.0 + (strength / 100.0) * factor
else:
factor = params.get('max_mouth_shrink', 0.25)
scale = 1.0 - (abs(strength) / 100.0) * factor
for idx in lips_indices:
pt = landmarks[idx]
src_pts.append(pt)
vec = pt - center
dst_pts.append(center + vec * scale)
for idx in anchor_idx:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt)
src_pts.append(center)
dst_pts.append(center)
return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups
def _config_nose_bridge(self, landmarks, strength, params):
"""
鼻梁变窄/变宽配置
Strength < 0: 变窄 (Narrow) - 更加立体/精致
Strength > 0: 变宽 (Widen)
"""
left_bridge_indices = [
193, 245, 128, 122, 121, 100
]
right_bridge_indices = [
417, 465, 357, 351, 350, 329
]
anchor_idx = [
1, 2, 4, 5, 6, 19, 48, 64, 94, 98, 115, 168, 195, 197, 278, 294, 327, 344,
362, 133,
359, 130,
123, 50, 116,
352, 280, 345
]
mask_idx_bridge = [9, 55, 97, 134, 164, 174, 188, 193, 220, 236, 237, 245, 285, 326, 363, 399, 412, 417, 440, 456, 457, 465]
mask_groups = [mask_idx_bridge]
src_pts = []
dst_pts = []
if strength >= 0:
factor = params.get('max_nose_widen_ratio', 0.3)
scale = 1.0 + (strength / 100.0) * factor
else:
factor = params.get('max_nose_narrow_ratio', 0.25)
scale = 1.0 - (abs(strength) / 100.0) * factor
vec_center_line = (landmarks[2] - landmarks[168]).astype(np.float32)
norm_cl = np.linalg.norm(vec_center_line)
if norm_cl > 0: vec_center_line /= norm_cl
def get_projection_point(pt, line_start, line_vec):
vec_ap = (pt - line_start).astype(np.float32)
proj_len = np.dot(vec_ap, line_vec)
return line_start + line_vec * proj_len
all_moving_indices = [3, 44, 45, 51, 122, 193, 196, 248, 274, 275, 281, 351, 417, 419]
for idx in all_moving_indices:
pt = landmarks[idx]
src_pts.append(pt)
proj_pt = get_projection_point(pt, landmarks[168], vec_center_line)
vec_radial = pt - proj_pt
dst_pts.append(proj_pt + vec_radial * scale)
for idx in anchor_idx:
pt = landmarks[idx]
src_pts.append(pt)
dst_pts.append(pt)
return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups
def apply_deformation(self, image, src_pts, dst_pts, mask_indices, landmarks, params):
h, w = image.shape[:2]
mls = SimilarityMLS(
grid_size=params.get('grid_size', 50),
alpha=params.get('alpha', 1.0)
)
warped_image = mls.warp(image, dst_pts, src_pts)
mask = self._create_roi_mask(
h, w, landmarks, mask_indices,
blur_ratio=params.get('blur_ratio', 0.08)
)
mask = mask[:, :, np.newaxis]
result = warped_image * mask + image * (1.0 - mask)
return result.astype(np.uint8)
def process_and_save(self, image_path, output_path, op_type='eye_resize', strength=50, hyperparams=None):
"""
单次操作的便捷入口,内部调用 process_batch
"""
operation = {
'op_type': op_type,
'strength': strength,
'params': hyperparams if hyperparams else {}
}
self.process_batch(image_path, output_path, [operation])
def process_batch(self, image_path, output_path, operations, diff_output_path=None):
"""
批量执行多个编辑操作 (Pipeline模式)
:param operations: 操作列表
:param diff_output_path: (新增) 指定差异图的保存路径,如果为None则不保存或使用默认命名
"""
img = cv2.imread(image_path)
if img is None:
print(f"Error: Could not read image: {image_path}")
return
arr_source = img.astype(np.float32)
current_img = img.copy()
for i, op in enumerate(operations):
op_type = op.get('op_type')
strength = op.get('strength')
custom_params = op.get('params', {})
landmarks = self._get_landmarks(current_img)
if landmarks is None:
break
params = self._get_default_params(op_type)
if custom_params:
params.update(custom_params)
try:
src_pts, dst_pts, mask_groups = self._get_operation_config(
op_type, landmarks, strength, params
)
current_img = self.apply_deformation(
current_img, src_pts, dst_pts, mask_groups, landmarks, params
)
except ValueError as e:
continue
os.makedirs(os.path.dirname(output_path), exist_ok=True)
cv2.imwrite(output_path, current_img)
class LLWFaceRetoucher(FaceEditor):
"""Public wrapper for Landmark-Guided Local Warping face retouching."""
def apply_operations_to_array(self, image_bgr: np.ndarray, operations: Sequence[Operation]) -> np.ndarray:
current_img = image_bgr.copy()
for op in operations:
op_type = str(op.get("op_type") or op.get("operation") or op.get("name"))
strength = float(op.get("strength", op.get("value", 100)))
custom_params = op.get("params", {}) or {}
landmarks = self._get_landmarks(current_img)
if landmarks is None:
raise RuntimeError("No face landmarks were detected before applying operation: " + op_type)
params = self._get_default_params(op_type)
params.update(custom_params)
src_pts, dst_pts, mask_groups = self._get_operation_config(op_type, landmarks, strength, params)
current_img = self.apply_deformation(current_img, src_pts, dst_pts, mask_groups, landmarks, params)
return current_img
def apply_operations(self, image_path: str | Path, operations: Sequence[Operation], output_path: str | Path | None = None) -> np.ndarray:
image = cv2.imread(str(image_path))
if image is None:
raise FileNotFoundError(f"Could not read image: {image_path}")
result = self.apply_operations_to_array(image, operations)
if output_path is not None:
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
cv2.imwrite(str(output_path), result)
return result
def normalize_operations(operation_specs: Iterable[str]) -> list[dict[str, object]]:
operations = []
for spec in operation_specs:
if ":" not in spec:
raise ValueError(f"Operation must be formatted as name:strength, got {spec!r}")
name, value = spec.split(":", 1)
operations.append({"op_type": name.strip(), "strength": float(value)})
return operations