forked from francescolorussi/MatchTransform
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMatchTransform.gizmo
More file actions
194 lines (194 loc) · 66.8 KB
/
Copy pathMatchTransform.gizmo
File metadata and controls
194 lines (194 loc) · 66.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# MatchTransform v1.0.0
# Automatic Image Alignment for Nuke
# Author: Francesco Lorussi
# Requires: NukeX
# https://github.com/francescolorussi/MatchTransform
Group {
name MatchTransform
knobChanged "n = nuke.thisNode()\nk = nuke.thisKnob()\n\nif k.name() == 'transform_type':\n if k.value() == 'Transform':\n n.knob('match_translate').setEnabled(True)\n n.knob('match_rotate').setEnabled(True)\n n.knob('match_scale').setEnabled(True)\n if n.knob('match_scale').value() == 1:\n n.knob('scale_mode').setEnabled(True)\n else:\n n.knob('scale_mode').setEnabled(False)\n n.knob('match_skew').setEnabled(True)\n \n if k.value() == 'CornerPin2D':\n n.knob('match_translate').setEnabled(False)\n n.knob('match_rotate').setEnabled(False)\n n.knob('match_scale').setEnabled(False)\n n.knob('scale_mode').setEnabled(False)\n n.knob('match_scale').setEnabled(False)\n n.knob('match_skew').setEnabled(False)\n \nif k.name() == 'match_scale':\n if k.value() == 1:\n n.knob('scale_mode').setEnabled(True)\n else:\n n.knob('scale_mode').setEnabled(False)\n \nif k.name() == 'analysis_range':\n if k.value() == 'Input Clips Range':\n n\['range_first_frame'].setEnabled(False)\n n\['range_last_frame'].setEnabled(False)\n elif k.value() == 'Custom Range':\n n\['range_first_frame'].setEnabled(True)\n n\['range_last_frame'].setEnabled(True)\n\n elif k.value() == 'Current Frame':\n n\['range_first_frame'].setEnabled(False)\n n\['range_last_frame'].setEnabled(False)\n else:\n pass\n \nif k.name() == 'ignore_mask':\n n.begin()\n ct_knob = nuke.toNode('CameraTracker1')\['ignore']\n \n if k.value() == 'None':\n ct_knob.setValue('None')\n if k.value() == 'Inputs Alpha':\n ct_knob.setValue('Source Alpha')\n if k.value() == 'Inputs Inverted Alpha':\n ct_knob.setValue('Source Inverted Alpha')\n\n\n\n "
tile_color 0x2c2c2cff
addUserKnob {20 match_transform l MatchTransform}
addUserKnob {26 analysis_tab l <b>Analysis</b>}
addUserKnob {4 analysis_range l "Analysis Range" t "Defines the range of frames to be analysed:\n\n<b>Custom Range</b> - Analyses the frame range defined by the Custom Range fields.\n<b>Input Clips Range</b> - Analyses the overlapping frame range between the Source and Reference inputs.\n<b>Current Frame</b> - Analyses only the current viewer frame." M {"Custom Range" "Input Clips Range" "Current Frame" "" "" "" "" ""}}
analysis_range "Current Frame"
addUserKnob {3 range_first_frame l "Custom Range" t "Defines first and last frame of the range to be analysed. Active only in Custom Range mode." +DISABLED}
range_first_frame 1
addUserKnob {3 range_last_frame l "" -STARTLINE +DISABLED}
range_last_frame 100
addUserKnob {4 ignore_mask l "Ignore Mask" t "Defines which areas of the inputs are excluded from feature tracking:\n\n<b>None</b> - All areas are tracked.\n<b>Inputs Alpha</b> - Areas masked by the input alpha channel are excluded.\n<b>Inputs Inverted Alpha</b> - Areas masked by the inverted input alpha channel are excluded." M {None "Inputs Alpha" "Inputs Inverted Alpha" "" "" "" ""}}
addUserKnob {26 _4 l "" +STARTLINE T " "}
addUserKnob {6 match_translate l Translate t "When enabled, the translation component is included in the motion solver and applied to the output." +DISABLED +STARTLINE}
match_translate true
addUserKnob {6 match_rotate l Rotate t "When enabled, the rotation component is included in the motion solver and applied to the output." +DISABLED +STARTLINE}
match_rotate true
addUserKnob {6 match_scale l Scale t "When enabled, the scale component is included in the motion solver and applied to the output." +DISABLED +STARTLINE}
match_scale true
addUserKnob {4 scale_mode l "" t "Defines how the scale component is applied to the output node:\n\n<b>Auto</b> - Automatically determines whether to use uniform or non-uniform scale based on the tracked data.\n<b>Uniform</b> - Forces a single scale value applied equally to both axes.\n<b>Non-Uniform</b> - Allows independent scale values on each axis." -STARTLINE +DISABLED M {Auto Uniform Non-Uniform "" "" "" "" "" "" "" "" ""}}
addUserKnob {6 match_skew l Skew t "When enabled, the skew component is included in the motion solver and applied to the output." +DISABLED +STARTLINE}
addUserKnob {26 _2 l "" +STARTLINE T " "}
addUserKnob {3 number_features l "Number of Features" t "Defines the number of features to detect for transform matching. Increasing this value can improve results on difficult shots, at the cost of a longer analysis time."}
number_features 5000
addUserKnob {4 points_filtering l "Features Filtering" t "Defines the filtering strength applied to tracked points before computing the transform:\n\n<b>Auto</b> - Adaptive filtering. Automatically finds the best filtering strength for the shot. More aggressive on clean footage, more permissive when motion blur or parallax is present. Recommended.\n<b>Off</b> - No filtering is applied. All tracked points are used. Fastest option.\n<b>Low</b> - Light filtering. Removes only obvious outliers, keeping most points.\n<b>Medium</b> - Moderate filtering. Balanced approach between accuracy and point count.\n<b>Strong</b> - Aggressive filtering. Keeps only the highest quality points." M {Auto Off Low Medium Strong "" "" "" "" "" ""}}
addUserKnob {26 _3 l "" +STARTLINE T " "}
addUserKnob {26 _1 l <b>Export</b>}
addUserKnob {4 transform_type l " " t "Defines the type of output node created by the match:\n\n<b>Transform</b> - Computes a 2D transform using the selected components (translation, rotation, scale, skew).\n<b>CornerPin2D</b> - Computes a full perspective match between the two inputs." M {Transform CornerPin2D "" "" "" ""}}
transform_type CornerPin2D
addUserKnob {22 run l Run t "Analyses the input frames and creates a Transform or CornerPin2D node with the computed match applied." -STARTLINE T "import math\nimport random\n\n\n# =============================================================================\n# INITIAL SETUP\n# =============================================================================\n\nnode = nuke.thisNode()\n\nnode.begin()\n\nframehold = nuke.toNode('FrameHold1')\ncameratracker = nuke.toNode('CameraTracker1')\nswitch = nuke.toNode('Switch1')\n\nw = framehold.width()\nh = framehold.height()\nsrc_w = nuke.toNode('Source').width()\nsrc_h = nuke.toNode('Source').height()\nsrc_par = nuke.toNode('Source').format().pixelAspect()\nref_w = nuke.toNode('Reference').width()\nref_h = nuke.toNode('Reference').height()\nref_par = nuke.toNode('Reference').format().pixelAspect()\n\nnode.end()\n\nis_nukex = (nuke.env\['nukex'])\n\n\n# =============================================================================\n# UTILITY FUNCTIONS\n# =============================================================================\n\ndef get_range():\n \"\"\"Return (first, last, frames_count) from the analysis range knobs.\"\"\"\n\n knob = node\['analysis_range']\n\n if knob.value() == 'Input Clips Range':\n first = max(\[node.input(0).firstFrame(), node.input(1).firstFrame()])\n last = min(\[node.input(0).lastFrame(), node.input(1).lastFrame()])\n elif knob.value() == 'Custom Range':\n first = int(node\['range_first_frame'].value())\n last = int(node\['range_last_frame'].value())\n elif knob.value() == 'Current Frame':\n first = nuke.frame()\n last = nuke.frame()\n else:\n first = nuke.frame()\n last = nuke.frame()\n\n first, last = (lambda a, b: (a, b) if a < b else (b, a))(first, last)\n frames_count = max(1, last - first + 1)\n\n return first, last, frames_count\n\n\ndef validate_points(points):\n \"\"\"Return True if there are enough points for the selected output type.\"\"\"\n\n transform_type = node\['transform_type'].value()\n points_len = len(points)\n if (\n (transform_type == \"Transform\" and points_len > 1) or\n (transform_type == \"CornerPin2D\" and points_len > 3)\n ):\n return True\n else:\n return False\n\n\n\n# =============================================================================\n# POINTS FILTERING PRESETS\n# =============================================================================\n\n# Thresholds are fractions of the image diagonal (~2203px at HD).\n\nTRANSFORM_PRESETS = \{\n 'off': \{\n 'use_ransac': False,\n 'threshold': 0,\n 'max_iterations': 0,\n 'min_inliers_ratio': 0,\n \},\n 'low': \{\n 'use_ransac': True,\n 'threshold': 0.0027, # ~6px at HD\n 'max_iterations': 800,\n 'min_inliers_ratio': 0.25,\n \},\n 'medium': \{\n 'use_ransac': True,\n 'threshold': 0.0016, # ~3.5px at HD\n 'max_iterations': 1500,\n 'min_inliers_ratio': 0.25,\n \},\n 'strong': \{\n 'use_ransac': True,\n 'threshold': 0.00091, # ~2px at HD\n 'max_iterations': 2500,\n 'min_inliers_ratio': 0.3,\n \}\n\}\n\nCORNERPIN_PRESETS = \{\n 'off': \{\n 'use_ransac': False,\n 'threshold': 0,\n 'max_iterations': 0,\n 'min_inliers_ratio': 0,\n \},\n 'low': \{\n 'use_ransac': True,\n 'threshold': 0.0036, # ~8px at HD\n 'max_iterations': 1500,\n 'min_inliers_ratio': 0.25,\n \},\n 'medium': \{\n 'use_ransac': True,\n 'threshold': 0.002, # ~4.5px at HD\n 'max_iterations': 2500,\n 'min_inliers_ratio': 0.25,\n \},\n 'strong': \{\n 'use_ransac': True,\n 'threshold': 0.0014, # ~3px at HD\n 'max_iterations': 4000,\n 'min_inliers_ratio': 0.3,\n \}\n\}\n\n# Adaptive threshold constants\nAUTO_INITIAL_THRESHOLD = 0.0027 # ~6px at HD\nAUTO_MAX_ITERATIONS = 2000\nAUTO_MIN_INLIERS_RATIO = 0.2\nAUTO_SIGMA_FACTOR = 2.5\nAUTO_MIN_THRESHOLD = 0.00045 # ~1px at HD\nAUTO_MAX_THRESHOLD = 0.0036 # ~8px at HD\n\n\n# =============================================================================\n# LINEAR ALGEBRA HELPERS\n# =============================================================================\n\ndef distance_squared(p1, p2):\n \"\"\"Return the squared Euclidean distance between two 2D points.\"\"\"\n\n return (p1\[0] - p2\[0])**2 + (p1\[1] - p2\[1])**2\n\n\ndef transform_point(point, matrix, translation):\n \"\"\"Apply a 2x2 matrix and translation to a 2D point.\"\"\"\n\n x, y = point\n m00, m01 = matrix\[0]\n m10, m11 = matrix\[1]\n tx, ty = translation\n\n x_new = m00 * x + m01 * y + tx\n y_new = m10 * x + m11 * y + ty\n return (x_new, y_new)\n\n\ndef solve_nxn(A, b):\n \"\"\"Solve Ax = b via Gaussian elimination with partial pivoting.\n Returns None if singular.\n \"\"\"\n n = len(A)\n M = \[A\[i]\[:] + \[b\[i]] for i in range(n)]\n\n for col in range(n):\n max_row = col\n for row in range(col + 1, n):\n if abs(M\[row]\[col]) > abs(M\[max_row]\[col]):\n max_row = row\n M\[col], M\[max_row] = M\[max_row], M\[col]\n\n if abs(M\[col]\[col]) < 1e-12:\n return None\n\n for row in range(col + 1, n):\n factor = M\[row]\[col] / M\[col]\[col]\n for j in range(col, n + 1):\n M\[row]\[j] -= factor * M\[col]\[j]\n\n x = \[0.0] * n\n for i in range(n - 1, -1, -1):\n x\[i] = M\[i]\[n]\n for j in range(i + 1, n):\n x\[i] -= M\[i]\[j] * x\[j]\n x\[i] /= M\[i]\[i]\n\n return x\n\n\ndef lm_iterate(params, residuals_fn, jacobian_fn, points, max_iter=10):\n \"\"\"Levenberg-Marquardt iterator.\n Minimises sum(residuals**2), returns refined params.\n \"\"\"\n n_p = len(params)\n lam = 1e-3\n\n for _ in range(max_iter):\n r = residuals_fn(params, points)\n J = jacobian_fn(params, points)\n m = len(r)\n\n JtJ = \[\[0.0] * n_p for _ in range(n_p)]\n Jtr = \[0.0] * n_p\n for k in range(m):\n for i in range(n_p):\n Jtr\[i] += J\[k]\[i] * r\[k]\n for j in range(n_p):\n JtJ\[i]\[j] += J\[k]\[i] * J\[k]\[j]\n\n JtJ_aug = \[row\[:] for row in JtJ]\n for i in range(n_p):\n JtJ_aug\[i]\[i] += lam * (JtJ\[i]\[i] + 1e-10)\n\n delta = solve_nxn(JtJ_aug, \[-v for v in Jtr])\n if delta is None:\n break\n\n new_params = \[params\[i] + delta\[i] for i in range(n_p)]\n old_cost = sum(v * v for v in r)\n if old_cost < 1e-20:\n break\n new_r = residuals_fn(new_params, points)\n new_cost = sum(v * v for v in new_r)\n\n if new_cost < old_cost:\n if old_cost - new_cost < 1e-12 * old_cost: # converged\n params = new_params\n break\n params = new_params\n lam /= 10.0\n else:\n lam *= 10.0\n if lam > 1e16: # diverged\n break\n\n return params\n\n\ndef lm_refine_rigid(model, points):\n \"\"\"LM refinement for rigid transform (3 DOF: theta, tx, ty).\"\"\"\n theta = math.radians(model\['rotation'])\n t = model\['translation']\n params = \[theta, t\[0], t\[1]]\n\n def residuals(p, pts):\n th, tx, ty = p\n c, s = math.cos(th), math.sin(th)\n r = \[]\n for (x, y), (xp, yp) in pts:\n r.append(c * x - s * y + tx - xp)\n r.append(s * x + c * y + ty - yp)\n return r\n\n def jacobian(p, pts):\n th = p\[0]\n c, s = math.cos(th), math.sin(th)\n J = \[]\n for (x, y), _ in pts:\n J.append(\[-s * x - c * y, 1.0, 0.0])\n J.append(\[ c * x - s * y, 0.0, 1.0])\n return J\n\n th, tx, ty = lm_iterate(params, residuals, jacobian, points)\n c, s = math.cos(th), math.sin(th)\n return \{\n 'matrix': \[\[c, -s], \[s, c]],\n 'translation': (tx, ty),\n 'scale': 1.0,\n 'rotation': math.degrees(th),\n 'centroid_source': model\['centroid_source'],\n 'centroid_target': model\['centroid_target'],\n \}\n\n\ndef lm_refine_homography(H, points):\n \"\"\"LM refinement for homography (8 DOF, h22=1 fixed).\n Minimises geometric reprojection error.\n \"\"\"\n params = \[H\[0]\[0], H\[0]\[1], H\[0]\[2],\n H\[1]\[0], H\[1]\[1], H\[1]\[2],\n H\[2]\[0], H\[2]\[1]]\n\n def residuals(p, pts):\n h00, h01, h02, h10, h11, h12, h20, h21 = p\n r = \[]\n for (x, y), (xp, yp) in pts:\n ww = h20 * x + h21 * y + 1.0\n if abs(ww) < 1e-10:\n r.append(0.0); r.append(0.0)\n continue\n r.append((h00 * x + h01 * y + h02) / ww - xp)\n r.append((h10 * x + h11 * y + h12) / ww - yp)\n return r\n\n def jacobian(p, pts):\n h00, h01, h02, h10, h11, h12, h20, h21 = p\n J = \[]\n for (x, y), _ in pts:\n ww = h20 * x + h21 * y + 1.0\n if abs(ww) < 1e-10:\n J.append(\[0.0] * 8); J.append(\[0.0] * 8)\n continue\n xh = (h00 * x + h01 * y + h02) / ww\n yh = (h10 * x + h11 * y + h12) / ww\n J.append(\[x / ww, y / ww, 1.0 / ww, 0.0, 0.0, 0.0, -x * xh / ww, -y * xh / ww])\n J.append(\[0.0, 0.0, 0.0, x / ww, y / ww, 1.0 / ww, -x * yh / ww, -y * yh / ww])\n return J\n\n h = lm_iterate(params, residuals, jacobian, points)\n return \[\[h\[0], h\[1], h\[2]], \[h\[3], h\[4], h\[5]], \[h\[6], h\[7], 1.0]]\n\n\n# =============================================================================\n# RANSAC ENGINE\n# =============================================================================\n\ndef inlier_indices_translation(model, points, threshold_sq):\n \"\"\"Return (indices, msac_score) for a pure-translation model.\"\"\"\n tx, ty = model\['translation']\n threshold = math.sqrt(threshold_sq)\n indices = \[]\n msac_score = 0.0\n for i, (p1, p2) in enumerate(points):\n err_sq = (p2\[0] - p1\[0] - tx) ** 2 + (p2\[1] - p1\[1] - ty) ** 2\n if err_sq <= threshold_sq:\n indices.append(i)\n msac_score += 1.0 - math.sqrt(err_sq) / threshold\n return indices, msac_score\n\n\ndef inlier_indices_linear(model, points, threshold_sq):\n \"\"\"Return (indices, msac_score) for linear models\n with 'matrix' and 'translation' keys.\n \"\"\"\n m, t = model\['matrix'], model\['translation']\n threshold = math.sqrt(threshold_sq)\n indices = \[]\n msac_score = 0.0\n for i, (p1, p2) in enumerate(points):\n err_sq = distance_squared(transform_point(p1, m, t), p2)\n if err_sq <= threshold_sq:\n indices.append(i)\n msac_score += 1.0 - math.sqrt(err_sq) / threshold\n return indices, msac_score\n\n\ndef inlier_indices_homography(H, points, threshold_sq):\n \"\"\"Return (indices, msac_score) using symmetric reprojection error.\"\"\"\n H_inv = invert_3x3(H)\n threshold = math.sqrt(threshold_sq)\n indices = \[]\n msac_score = 0.0\n for i, (p1, p2) in enumerate(points):\n p1_t = apply_homography(H, p1)\n d_fwd = distance_squared(p1_t, p2)\n if H_inv is not None:\n p2_t = apply_homography(H_inv, p2)\n d_total = d_fwd + distance_squared(p2_t, p1)\n if d_total <= 2 * threshold_sq:\n indices.append(i)\n msac_score += 1.0 - math.sqrt(d_total * 0.5) / threshold\n else:\n if d_fwd <= threshold_sq:\n indices.append(i)\n msac_score += 1.0 - math.sqrt(d_fwd) / threshold\n return indices, msac_score\n\n\ndef ransac(points, solver_fn, inlier_indices_fn, sample_size, threshold, max_iterations,\n min_inliers_ratio=0.0, refit_fn=None, postprocess_fn=None, weights=None):\n \"\"\"\n Generic RANSAC with MSAC scoring, adaptive termination, Lo-RANSAC polishing,\n and quality gate.\n \"\"\"\n if refit_fn is None:\n refit_fn = solver_fn\n\n n = len(points)\n confidence = 0.99\n threshold_sq = threshold * threshold\n use_weights = weights is not None and len(weights) == n\n\n n_combos = 1\n for i in range(sample_size):\n n_combos = n_combos * (n - i) // (i + 1)\n max_iterations = min(max_iterations, max(1, n_combos))\n\n best_indices = \[]\n best_score = 0.0\n\n for iteration in range(max_iterations):\n if n <= sample_size:\n sample_idx = list(range(n))\n elif use_weights:\n pool = list(range(n))\n pool_w = list(weights)\n sample_idx = \[]\n for _ in range(sample_size):\n picked = random.choices(range(len(pool)), weights=pool_w, k=1)\[0]\n sample_idx.append(pool\[picked])\n pool.pop(picked)\n pool_w.pop(picked)\n else:\n sample_idx = random.sample(range(n), sample_size)\n\n sample = \[points\[i] for i in sample_idx]\n\n try:\n model = solver_fn(sample)\n if model is None:\n continue\n\n inlier_idx, msac_score = inlier_indices_fn(model, points, threshold_sq)\n\n # weight MSAC score by CameraTracker confidence\n if use_weights:\n score = sum(weights\[i] for i in inlier_idx) * (msac_score / max(len(inlier_idx), 1))\n else:\n score = msac_score\n\n if score > best_score:\n best_indices = inlier_idx\n best_score = score\n\n # adaptive early termination\n inlier_ratio = len(best_indices) / n\n if 0 < inlier_ratio < 1:\n denom = math.log(1.0 - inlier_ratio ** sample_size)\n if denom < -1e-10:\n n_adaptive = math.log(1.0 - confidence) / denom\n if iteration + 1 >= n_adaptive:\n break\n\n except (ValueError, ZeroDivisionError):\n continue\n\n # no inliers found -> direct solve on all points\n if not best_indices:\n result = refit_fn(points)\n return result if result is not None else solver_fn(points)\n\n # Lo-RANSAC polishing\n for _ in range(3):\n best_inliers = \[points\[i] for i in best_indices]\n refit = refit_fn(best_inliers)\n if refit is None:\n break\n new_indices, new_msac = inlier_indices_fn(refit, points, threshold_sq)\n if use_weights:\n new_score = sum(weights\[i] for i in new_indices) * (new_msac / max(len(new_indices), 1))\n old_score = best_score\n else:\n new_score = new_msac\n old_score = best_score\n if new_score <= old_score:\n break\n best_indices = new_indices\n best_score = new_score\n\n if min_inliers_ratio > 0 and len(best_indices) / n < min_inliers_ratio:\n raise ValueError('Filtering discarded too many points')\n\n best_inliers = \[points\[i] for i in best_indices]\n result = refit_fn(best_inliers)\n if result is None:\n result = refit_fn(points)\n\n if postprocess_fn is not None:\n result = postprocess_fn(result, best_inliers)\n\n return result\n\n\n# =============================================================================\n# TRANSLATION SOLVER\n# =============================================================================\n\ndef mean_translation(points):\n \"\"\"Pure translation: optimal tx, ty as mean of all point displacements.\"\"\"\n n = len(points)\n tx = sum(p2\[0] - p1\[0] for p1, p2 in points) / n\n ty = sum(p2\[1] - p1\[1] for p1, p2 in points) / n\n return \{'translation': (tx, ty)\}\n\n\ndef ransac_translation(points, threshold, max_iterations, min_inliers_ratio=0.0, weights=None):\n \"\"\"RANSAC for pure translation (1-point minimal sample).\"\"\"\n return ransac(points, mean_translation, inlier_indices_translation, 1,\n threshold, max_iterations, min_inliers_ratio,\n weights=weights)\n\n\n# =============================================================================\n# RIGID / SIMILARITY SOLVER\n# =============================================================================\n\ndef procrustes_similarity(points, force_unit_scale=False):\n \"\"\"Closed-form similarity transform (Umeyama).\n If force_unit_scale=True, acts as rigid (scale=1).\n \"\"\"\n n = len(points)\n\n c1_x = sum(p1\[0] for p1, _ in points) / n\n c1_y = sum(p1\[1] for p1, _ in points) / n\n c2_x = sum(p2\[0] for _, p2 in points) / n\n c2_y = sum(p2\[1] for _, p2 in points) / n\n\n q1 = \[(p1\[0] - c1_x, p1\[1] - c1_y) for p1, _ in points]\n q2 = \[(p2\[0] - c2_x, p2\[1] - c2_y) for _, p2 in points]\n\n norm1_sq = sum(x*x + y*y for x, y in q1)\n if norm1_sq < 1e-10:\n raise ValueError(\"Source points are too close together for similarity estimation\")\n\n h11 = sum(q2\[i]\[0] * q1\[i]\[0] for i in range(n))\n h12 = sum(q2\[i]\[0] * q1\[i]\[1] for i in range(n))\n h21 = sum(q2\[i]\[1] * q1\[i]\[0] for i in range(n))\n h22 = sum(q2\[i]\[1] * q1\[i]\[1] for i in range(n))\n\n rotation_rad = math.atan2(h21 - h12, h11 + h22)\n rotation_deg = math.degrees(rotation_rad)\n cos_t = math.cos(rotation_rad)\n sin_t = math.sin(rotation_rad)\n\n if force_unit_scale:\n scale = 1.0\n else:\n cross_norm = math.sqrt((h11 + h22) ** 2 + (h21 - h12) ** 2)\n scale = cross_norm / norm1_sq\n\n m00 = scale * cos_t\n m01 = -scale * sin_t\n m10 = scale * sin_t\n m11 = scale * cos_t\n\n matrix = \[\[m00, m01], \[m10, m11]]\n tx = c2_x - (m00 * c1_x + m01 * c1_y)\n ty = c2_y - (m10 * c1_x + m11 * c1_y)\n\n return \{\n 'matrix': matrix,\n 'translation': (tx, ty),\n 'scale': scale,\n 'rotation': rotation_deg,\n 'centroid_source': (c1_x, c1_y),\n 'centroid_target': (c2_x, c2_y),\n \}\n\n\ndef ransac_rigid_transform(points, threshold, max_iterations, min_inliers_ratio=0.0, weights=None):\n \"\"\"RANSAC for rigid body transform (2-point minimal sample)\n with LM refinement.\n \"\"\"\n def rigid_solver(pts):\n return procrustes_similarity(pts, force_unit_scale=True)\n return ransac(points, rigid_solver, inlier_indices_linear, 2,\n threshold, max_iterations, min_inliers_ratio,\n postprocess_fn=lm_refine_rigid, weights=weights)\n\n\ndef ransac_similarity_transform(points, threshold, max_iterations, min_inliers_ratio=0.0, weights=None):\n \"\"\"RANSAC for similarity transform (2-point minimal sample).\"\"\"\n return ransac(points, procrustes_similarity, inlier_indices_linear, 2,\n threshold, max_iterations, min_inliers_ratio,\n weights=weights)\n\n\n# =============================================================================\n# AFFINE SOLVER\n# =============================================================================\n\ndef least_squares_affine(points):\n \"\"\"Fit a 6-parameter affine via least squares. Returns None if degenerate.\"\"\"\n n = len(points)\n\n S00 = sum(p1\[0] * p1\[0] for p1, _ in points)\n S01 = sum(p1\[0] * p1\[1] for p1, _ in points)\n S11 = sum(p1\[1] * p1\[1] for p1, _ in points)\n S0 = sum(p1\[0] for p1, _ in points)\n S1 = sum(p1\[1] for p1, _ in points)\n\n AtA = \[\n \[S00, S01, S0],\n \[S01, S11, S1],\n \[S0, S1, n ]\n ]\n\n bx = \[\n sum(p1\[0] * p2\[0] for p1, p2 in points),\n sum(p1\[1] * p2\[0] for p1, p2 in points),\n sum(p2\[0] for _, p2 in points)\n ]\n\n by = \[\n sum(p1\[0] * p2\[1] for p1, p2 in points),\n sum(p1\[1] * p2\[1] for p1, p2 in points),\n sum(p2\[1] for _, p2 in points)\n ]\n\n params_x = solve_nxn(AtA, bx)\n params_y = solve_nxn(AtA, by)\n\n if params_x is None or params_y is None:\n return None\n\n a, b, tx = params_x\n c, d, ty = params_y\n\n c1_x = S0 / n\n c1_y = S1 / n\n c2_x = sum(p2\[0] for _, p2 in points) / n\n c2_y = sum(p2\[1] for _, p2 in points) / n\n\n return \{\n 'matrix': \[\[a, b], \[c, d]],\n 'translation': (tx, ty),\n 'centroid_source': (c1_x, c1_y),\n 'centroid_target': (c2_x, c2_y),\n \}\n\n\ndef decompose_affine_2x2(M):\n \"\"\"Decompose a 2x2 affine matrix into Nuke parameters via polar decomposition.\n Returns (sx, sy, rotation_deg, skewX, skewY).\n \"\"\"\n a, b = M\[0]\n c, d = M\[1]\n\n det = a * d - b * c\n\n p = a*a + c*c\n q = a*b + c*d\n r = b*b + d*d\n\n mid = (p + r) * 0.5\n sq = math.sqrt(max((p - r) * (p - r) * 0.25 + q*q, 0.0))\n lam1 = mid + sq\n lam2 = mid - sq\n\n s1 = math.sqrt(max(lam1, 0.0))\n s2 = math.sqrt(max(lam2, 0.0))\n\n if det < 0:\n s2 = -s2\n\n if abs(q) > 1e-10:\n v1x = lam1 - r\n v1y = q\n nrm = math.sqrt(v1x*v1x + v1y*v1y)\n v1x /= nrm; v1y /= nrm\n elif p >= r:\n v1x, v1y = 1.0, 0.0\n else:\n v1x, v1y = 0.0, 1.0\n v2x, v2y = -v1y, v1x\n\n inv_s1 = 1.0 / s1 if s1 > 1e-10 else 0.0\n inv_s2 = 1.0 / s2 if s2 > 1e-10 else 0.0\n Si00 = v1x*inv_s1*v1x + v2x*inv_s2*v2x\n Si01 = v1x*inv_s1*v1y + v2x*inv_s2*v2y\n Si10 = v1y*inv_s1*v1x + v2y*inv_s2*v2x\n Si11 = v1y*inv_s1*v1y + v2y*inv_s2*v2y\n\n U00 = a*Si00 + b*Si10; U01 = a*Si01 + b*Si11\n U10 = c*Si00 + d*Si10; U11 = c*Si01 + d*Si11\n\n rotation_rad = math.atan2(U10, U00)\n\n B00 = U00*a + U10*c; B01 = U00*b + U10*d\n B10 = U01*a + U11*c; B11 = U01*b + U11*d\n\n sy = B11\n skew_x = B01 / sy if abs(sy) > 1e-10 else 0.0\n sx = B00 - skew_x * B10\n skew_y = B10 / sx if abs(sx) > 1e-10 else 0.0\n\n return sx, sy, math.degrees(rotation_rad), skew_x, skew_y\n\n\ndef ransac_affine_transform(points, threshold, max_iterations, min_inliers_ratio=0.0, weights=None):\n \"\"\"RANSAC for affine transform (3-point minimal sample).\"\"\"\n def refit(pts):\n r = least_squares_affine(pts)\n return r if r is not None else procrustes_similarity(pts)\n return ransac(points, least_squares_affine, inlier_indices_linear, 3,\n threshold, max_iterations, min_inliers_ratio,\n refit_fn=refit, weights=weights)\n\n\n# =============================================================================\n# HOMOGRAPHY SOLVER\n# =============================================================================\n\ndef normalize_points(points):\n \"\"\"Hartley normalization: translate to centroid, scale to mean distance = sqrt(2).\n Returns (normalized_points, T).\n \"\"\"\n n = len(points)\n\n cx = sum(p\[0] for p in points) / n\n cy = sum(p\[1] for p in points) / n\n\n centered = \[(p\[0] - cx, p\[1] - cy) for p in points]\n\n mean_dist = sum(math.sqrt(x*x + y*y) for x, y in centered) / n\n\n if mean_dist < 1e-10:\n scale = 1.0\n else:\n scale = math.sqrt(2.0) / mean_dist\n\n normalized = \[(x * scale, y * scale) for x, y in centered]\n\n T = \[\n \[scale, 0, -scale * cx],\n \[0, scale, -scale * cy],\n \[0, 0, 1]\n ]\n\n return normalized, T\n\n\ndef matrix_multiply_3x3(A, B):\n \"\"\"Return the 3x3 matrix product A * B.\"\"\"\n result = \[\[0.0 for _ in range(3)] for _ in range(3)]\n for i in range(3):\n for j in range(3):\n for k in range(3):\n result\[i]\[j] += A\[i]\[k] * B\[k]\[j]\n return result\n\n\ndef jacobi_eigen_optimized(A, eps=1e-10, max_iter=1000):\n \"\"\"Jacobi eigendecomposition for a real symmetric matrix.\n Returns (eigenvalues, V) where columns of V are eigenvectors.\n \"\"\"\n n = len(A)\n A = \[row\[:] for row in A]\n V = \[\[1.0 if i == j else 0.0 for j in range(n)] for i in range(n)]\n\n for _ in range(max_iter):\n max_val = 0.0\n p = 0\n q = 1\n for i in range(n):\n for j in range(i+1, n):\n if abs(A\[i]\[j]) > abs(max_val):\n max_val = A\[i]\[j]\n p, q = i, j\n\n if abs(max_val) < eps:\n break\n\n phi = 0.5 * math.atan2(2 * A\[p]\[q], A\[q]\[q] - A\[p]\[p])\n c = math.cos(phi)\n s = math.sin(phi)\n\n app = A\[p]\[p]\n aqq = A\[q]\[q]\n Apq = A\[p]\[q]\n\n A\[p]\[p] = c*c*app - 2*s*c*Apq + s*s*aqq\n A\[q]\[q] = s*s*app + 2*s*c*Apq + c*c*aqq\n A\[p]\[q] = 0.0\n A\[q]\[p] = 0.0\n\n for j in range(n):\n if j != p and j != q:\n Aip = A\[p]\[j]\n Aiq = A\[q]\[j]\n A\[p]\[j] = c*Aip - s*Aiq\n A\[j]\[p] = A\[p]\[j]\n A\[q]\[j] = s*Aip + c*Aiq\n A\[j]\[q] = A\[q]\[j]\n\n for i in range(n):\n Vip = V\[i]\[p]\n Viq = V\[i]\[q]\n V\[i]\[p] = c*Vip - s*Viq\n V\[i]\[q] = s*Vip + c*Viq\n\n eigenvalues = \[A\[i]\[i] for i in range(n)]\n return eigenvalues, V\n\n\ndef homography_4point(sample):\n \"\"\"Solve homography from 4 correspondences (8x8 linear system, h22=1 fixed).\n Returns 3x3 matrix or None if degenerate.\n \"\"\"\n A = \[]\n b = \[]\n for (x, y), (xp, yp) in sample:\n A.append(\[x, y, 1.0, 0.0, 0.0, 0.0, -x * xp, -y * xp])\n b.append(xp)\n A.append(\[0.0, 0.0, 0.0, x, y, 1.0, -x * yp, -y * yp])\n b.append(yp)\n h = solve_nxn(A, b)\n if h is None:\n return None\n return \[\[h\[0], h\[1], h\[2]], \[h\[3], h\[4], h\[5]], \[h\[6], h\[7], 1.0]]\n\n\ndef homography_normalized(points):\n \"\"\"Normalized DLT homography via Hartley normalization\n and smallest eigenvector of A^T*A.\n \"\"\"\n source_points = \[p1 for p1, _ in points]\n target_points = \[p2 for _, p2 in points]\n\n norm_source, T1 = normalize_points(source_points)\n norm_target, T2 = normalize_points(target_points)\n\n A = \[]\n for i in range(len(points)):\n x, y = norm_source\[i]\n xp, yp = norm_target\[i]\n A.append(\[-x, -y, -1, 0, 0, 0, x*xp, y*xp, xp])\n A.append(\[ 0, 0, 0, -x, -y, -1, x*yp, y*yp, yp])\n\n nrows = len(A)\n B = \[\[0.0 for _ in range(9)] for _ in range(9)]\n for i in range(9):\n for j in range(9):\n s = 0.0\n for r in range(nrows):\n s += A\[r]\[i] * A\[r]\[j]\n B\[i]\[j] = s\n\n eigenvalues, V = jacobi_eigen_optimized(B)\n min_index = 0\n min_val = eigenvalues\[0]\n for i in range(1, 9):\n if eigenvalues\[i] < min_val:\n min_val = eigenvalues\[i]\n min_index = i\n\n h = \[V\[i]\[min_index] for i in range(9)]\n H_norm = \[h\[0:3], h\[3:6], h\[6:9]]\n\n if abs(H_norm\[2]\[2]) > 1e-10:\n for i in range(3):\n for j in range(3):\n H_norm\[i]\[j] /= H_norm\[2]\[2]\n\n scale2 = T2\[0]\[0]\n cx2 = -T2\[0]\[2] / scale2\n cy2 = -T2\[1]\[2] / scale2\n\n T2_inv = \[\n \[1.0/scale2, 0, cx2],\n \[0, 1.0/scale2, cy2],\n \[0, 0, 1]\n ]\n\n temp = matrix_multiply_3x3(H_norm, T1)\n H = matrix_multiply_3x3(T2_inv, temp)\n\n if abs(H\[2]\[2]) > 1e-10:\n for i in range(3):\n for j in range(3):\n H\[i]\[j] /= H\[2]\[2]\n\n return H\n\n\ndef apply_homography(H, pt):\n \"\"\"Apply 3x3 homography H to 2D point, returns (x', y').\"\"\"\n x, y = pt\n x_new = H\[0]\[0]*x + H\[0]\[1]*y + H\[0]\[2]\n y_new = H\[1]\[0]*x + H\[1]\[1]*y + H\[1]\[2]\n w_new = H\[2]\[0]*x + H\[2]\[1]*y + H\[2]\[2]\n\n if abs(w_new) < 1e-10:\n return (x_new, y_new)\n return (x_new / w_new, y_new / w_new)\n\n\ndef invert_3x3(M):\n \"\"\"Return the inverse of a 3x3 matrix, or None if singular.\"\"\"\n a, b, c = M\[0]\n d, e, f = M\[1]\n g, h, i = M\[2]\n\n det = a*(e*i - f*h) - b*(d*i - f*g) + c*(d*h - e*g)\n if abs(det) < 1e-10:\n return None\n\n inv_det = 1.0 / det\n return \[\n \[(e*i - f*h)*inv_det, (c*h - b*i)*inv_det, (b*f - c*e)*inv_det],\n \[(f*g - d*i)*inv_det, (a*i - c*g)*inv_det, (c*d - a*f)*inv_det],\n \[(d*h - e*g)*inv_det, (b*g - a*h)*inv_det, (a*e - b*d)*inv_det],\n ]\n\n\ndef ransac_homography(points, threshold, max_iterations, min_inliers_ratio=0.0, weights=None):\n \"\"\"RANSAC for homography with LM refinement.\"\"\"\n return ransac(points, homography_4point, inlier_indices_homography, 4,\n threshold, max_iterations, min_inliers_ratio,\n refit_fn=homography_normalized,\n postprocess_fn=lm_refine_homography, weights=weights)\n\n\n# =============================================================================\n# TRANSFORM DISPATCH\n# =============================================================================\n\ndef bic_linear(model, points, threshold_sq, n_params):\n \"\"\"BIC criterion on inliers, used for similarity vs affine model selection.\"\"\"\n m = model\['matrix']\n t = model\['translation']\n n = 0\n sse = 0.0\n for p in points:\n d = distance_squared(transform_point(p\[0], m, t), p\[1])\n if d <= threshold_sq:\n sse += d\n n += 1\n if n < n_params + 1:\n return float('inf')\n return n_params * math.log(n) + n * math.log(max(sse / n, 1e-20))\n\n\ndef fixed_point(matrix, translation):\n \"\"\"Return the fixed point p* where M*p* + t = p*, or None if (I-M) is singular.\"\"\"\n m00, m01 = matrix\[0]\n m10, m11 = matrix\[1]\n tx, ty = translation\n a, b = 1.0 - m00, -m01\n c, d = -m10, 1.0 - m11\n det = a * d - b * c\n if abs(det) < 1e-10:\n return None\n return ((d * tx - b * ty) / det, (a * ty - c * tx) / det)\n\n\ndef nuke_translate(matrix, t_fit, center, match_translate):\n \"\"\"Return (translate, center) in Nuke convention.\n If match_translate is False, uses fixed point as center.\n \"\"\"\n if not match_translate:\n fp = fixed_point(matrix, t_fit)\n return (0.0, 0.0), (fp if fp is not None else center)\n m = matrix\n tx = t_fit\[0] + (m\[0]\[0] * center\[0] + m\[0]\[1] * center\[1]) - center\[0]\n ty = t_fit\[1] + (m\[1]\[0] * center\[0] + m\[1]\[1] * center\[1]) - center\[1]\n return (tx, ty), center\n\n\ndef adaptive_ransac(points, solver_fn, inlier_indices_fn, sample_size, w, h,\n refit_fn=None, postprocess_fn=None, weights=None):\n \"\"\"Two-pass RANSAC: rough pass to find inliers,\n then sigma-clipping to refine the threshold.\n \"\"\"\n diag = math.sqrt(w * w + h * h)\n threshold_initial = AUTO_INITIAL_THRESHOLD * diag\n threshold_sq_initial = threshold_initial * threshold_initial\n\n # Pass 1\n model = ransac(\n points, solver_fn, inlier_indices_fn, sample_size,\n threshold=threshold_initial,\n max_iterations=AUTO_MAX_ITERATIONS,\n min_inliers_ratio=0.0, # no quality gate on pass 1\n refit_fn=refit_fn,\n postprocess_fn=postprocess_fn,\n weights=weights,\n )\n if model is None:\n raise ValueError('Filtering discarded too many points')\n\n # Compute inlier residual stdev\n inlier_idx, _ = inlier_indices_fn(model, points, threshold_sq_initial)\n if len(inlier_idx) < sample_size + 1:\n return model # too few inliers to estimate stdev\n\n residuals_sq = \[]\n if isinstance(model, list):\n # homography: model is a 3x3 matrix\n for i in inlier_idx:\n p1, p2 = points\[i]\n pt = apply_homography(model, p1)\n residuals_sq.append(distance_squared(pt, p2))\n elif 'matrix' in model:\n m, t = model\['matrix'], model\['translation']\n for i in inlier_idx:\n p1, p2 = points\[i]\n pt = transform_point(p1, m, t)\n residuals_sq.append(distance_squared(pt, p2))\n elif 'translation' in model:\n tx, ty = model\['translation']\n for i in inlier_idx:\n p1, p2 = points\[i]\n residuals_sq.append((p2\[0] - p1\[0] - tx)**2 + (p2\[1] - p1\[1] - ty)**2)\n else:\n return model\n\n n_inliers = len(residuals_sq)\n mean_sq = sum(residuals_sq) / n_inliers\n stdev = math.sqrt(max(mean_sq, 0.0))\n\n threshold_refined = AUTO_SIGMA_FACTOR * stdev\n min_t = AUTO_MIN_THRESHOLD * diag\n max_t = AUTO_MAX_THRESHOLD * diag\n threshold_refined = max(min_t, min(threshold_refined, max_t))\n\n # Pass 2\n return ransac(\n points, solver_fn, inlier_indices_fn, sample_size,\n threshold=threshold_refined,\n max_iterations=AUTO_MAX_ITERATIONS,\n min_inliers_ratio=AUTO_MIN_INLIERS_RATIO,\n refit_fn=refit_fn,\n postprocess_fn=postprocess_fn,\n weights=weights,\n )\n\n\ndef compute_transform(points, w, h, preset_name, weights=None):\n \"\"\"Compute Transform parameters from point correspondences.\n Solver chosen by active DOF knobs; 'Auto' scale uses BIC to select similarity vs affine.\n \"\"\"\n scale_behavior = node\['scale_mode'].value()\n match_translate = node\['match_translate'].value() == 1\n match_rotate = node\['match_rotate'].value() == 1\n match_scale = node\['match_scale'].value() == 1\n match_skew = node\['match_skew'].value() == 1\n\n use_affine = match_skew or (match_scale and scale_behavior in ('Non-Uniform', 'Auto'))\n use_rigid = match_rotate and not match_scale and not match_skew\n is_auto = (preset_name == 'auto')\n\n diag = math.sqrt(w * w + h * h)\n new_center = (w / 2.0, h / 2.0)\n\n if is_auto:\n use_ransac = True\n threshold_sq = (AUTO_INITIAL_THRESHOLD * diag) ** 2 # for BIC\n else:\n preset = TRANSFORM_PRESETS.get(preset_name)\n use_ransac = preset\['use_ransac']\n min_ratio = preset\['min_inliers_ratio']\n threshold = preset\['threshold'] * diag\n max_iter = preset\['max_iterations']\n threshold_sq = threshold * threshold\n\n # Translate only\n if not match_rotate and not match_scale and not match_skew:\n if is_auto:\n model = adaptive_ransac(\n points, mean_translation, inlier_indices_translation, 1,\n w, h, weights=weights)\n elif use_ransac:\n model = ransac_translation(points, threshold, max_iter, min_ratio, weights=weights)\n else:\n model = mean_translation(points)\n\n tx, ty = model\['translation']\n transform = \{\n 'translate': (tx, ty),\n 'rotate': 0.0,\n 'scale': 1.0,\n 'skewX': 0.0,\n 'skewY': 0.0,\n 'center': new_center,\n \}\n return clamp_transform_params(transform, w, h)\n\n use_affine_final = False\n\n if use_rigid:\n if is_auto:\n def rigid_solver(pts):\n return procrustes_similarity(pts, force_unit_scale=True)\n model = adaptive_ransac(\n points, rigid_solver, inlier_indices_linear, 2,\n w, h, postprocess_fn=lm_refine_rigid, weights=weights)\n elif use_ransac:\n model = ransac_rigid_transform(points, threshold, max_iter, min_ratio, weights=weights)\n else:\n model = procrustes_similarity(points, force_unit_scale=True)\n\n elif use_affine:\n use_affine_final = True\n\n # BIC model selection: Auto scale, no skew\n if (use_ransac or is_auto) and scale_behavior == 'Auto' and match_scale and not match_skew:\n sim_model = None\n aff_model = None\n try:\n if is_auto:\n sim_model = adaptive_ransac(\n points, procrustes_similarity, inlier_indices_linear, 2,\n w, h, weights=weights)\n else:\n sim_model = ransac_similarity_transform(points, threshold, max_iter, 0.0, weights=weights)\n except ValueError:\n pass\n try:\n if is_auto:\n def aff_refit(pts):\n r = least_squares_affine(pts)\n return r if r is not None else procrustes_similarity(pts)\n aff_model = adaptive_ransac(\n points, least_squares_affine, inlier_indices_linear, 3,\n w, h, refit_fn=aff_refit, weights=weights)\n else:\n aff_model = ransac_affine_transform(points, threshold, max_iter, 0.0, weights=weights)\n except ValueError:\n pass\n\n if sim_model is None and aff_model is None:\n raise RuntimeError(\"Feature filtering removed all points, no consistent match found\")\n elif sim_model is None:\n model = aff_model\n elif aff_model is None:\n model = sim_model\n use_affine_final = False\n else:\n sim_bic = bic_linear(sim_model, points, threshold_sq, 4)\n aff_bic = bic_linear(aff_model, points, threshold_sq, 6)\n if sim_bic <= aff_bic:\n model = sim_model\n use_affine_final = False\n else:\n model = aff_model\n\n elif is_auto:\n def aff_refit(pts):\n r = least_squares_affine(pts)\n return r if r is not None else procrustes_similarity(pts)\n model = adaptive_ransac(\n points, least_squares_affine, inlier_indices_linear, 3,\n w, h, refit_fn=aff_refit, weights=weights)\n elif use_ransac:\n model = ransac_affine_transform(points, threshold, max_iter, min_ratio, weights=weights)\n else:\n model = least_squares_affine(points) or procrustes_similarity(points)\n\n else: # similarity\n if is_auto:\n model = adaptive_ransac(\n points, procrustes_similarity, inlier_indices_linear, 2,\n w, h, weights=weights)\n elif use_ransac:\n model = ransac_similarity_transform(points, threshold, max_iter, min_ratio, weights=weights)\n else:\n model = procrustes_similarity(points)\n\n if use_affine_final:\n sx, sy, rotation_deg, skew_x, skew_y = decompose_affine_2x2(model\['matrix'])\n scale = (sx, sy)\n else:\n rotation_deg = model\['rotation']\n scale = model\['scale']\n skew_x, skew_y = 0.0, 0.0\n\n translate, new_center = nuke_translate(\n model\['matrix'], model\['translation'], new_center, match_translate)\n\n transform = \{\n 'translate': translate,\n 'rotate': rotation_deg,\n 'scale': scale,\n 'skewX': skew_x,\n 'skewY': skew_y,\n 'center': new_center,\n \}\n return clamp_transform_params(transform, w, h)\n\n\ndef clamp_transform_params(transform, w, h):\n \"\"\"Clamp sub-pixel parameters to identity and round to meaningful precision.\"\"\"\n CLAMP_PX = 0.5 # clamp to identity if effect is below this\n ROUND_PX = 0.05 # round away precision finer than this\n diag = math.sqrt(w * w + h * h)\n\n def n_decimals(sensitivity):\n if sensitivity < 1e-10:\n return 6\n return max(0, int(math.ceil(math.log10(sensitivity / (2.0 * ROUND_PX)))))\n\n def round_val(value, sensitivity):\n n_max = n_decimals(sensitivity)\n for n in range(n_max + 1):\n rounded = round(value, n)\n if abs(rounded - value) * sensitivity <= ROUND_PX:\n return rounded\n return round(value, n_max)\n\n # Translate\n tx, ty = transform\['translate']\n tx = 0.0 if abs(tx) < CLAMP_PX else round_val(tx, 1.0)\n ty = 0.0 if abs(ty) < CLAMP_PX else round_val(ty, 1.0)\n transform\['translate'] = (tx, ty)\n\n # Rotate\n rot_sens = diag * 0.5 * math.pi / 180.0 # px per degree\n if abs(math.radians(transform\['rotate'])) * diag * 0.5 < CLAMP_PX:\n transform\['rotate'] = 0.0\n else:\n transform\['rotate'] = round_val(transform\['rotate'], rot_sens)\n\n # Scale\n scale_sens = max(w, h) * 0.5 # px per unit of scale (at image edge)\n scale = transform\['scale']\n if isinstance(scale, tuple):\n sx, sy = scale\n sx = 1.0 if abs(sx - 1.0) * diag * 0.5 < CLAMP_PX else round_val(sx, scale_sens)\n sy = 1.0 if abs(sy - 1.0) * diag * 0.5 < CLAMP_PX else round_val(sy, scale_sens)\n transform\['scale'] = sx if sx == sy else (sx, sy)\n else:\n if abs(scale - 1.0) * diag * 0.5 < CLAMP_PX:\n transform\['scale'] = 1.0\n else:\n transform\['scale'] = round_val(scale, scale_sens)\n\n # SkewX / SkewY\n if abs(transform\['skewX']) * h * 0.5 < CLAMP_PX:\n transform\['skewX'] = 0.0\n else:\n transform\['skewX'] = round_val(transform\['skewX'], h * 0.5)\n\n if abs(transform\['skewY']) * w * 0.5 < CLAMP_PX:\n transform\['skewY'] = 0.0\n else:\n transform\['skewY'] = round_val(transform\['skewY'], w * 0.5)\n\n return transform\n\n\ndef compute_cornerpin(points, w, h, preset_name, weights=None):\n \"\"\"Compute CornerPin2D parameters by fitting a homography\n and mapping the four corners.\n \"\"\"\n is_auto = (preset_name == 'auto')\n\n if is_auto:\n H = adaptive_ransac(\n points, homography_4point, inlier_indices_homography, 4,\n w, h,\n refit_fn=homography_normalized,\n postprocess_fn=lm_refine_homography,\n weights=weights,\n )\n else:\n preset = CORNERPIN_PRESETS.get(preset_name)\n diag = math.sqrt(w * w + h * h)\n\n if preset\['use_ransac']:\n H = ransac_homography(\n points,\n threshold=preset\['threshold'] * diag,\n max_iterations=preset\['max_iterations'],\n min_inliers_ratio=preset\['min_inliers_ratio'],\n weights=weights,\n )\n else:\n H = homography_normalized(points)\n\n from_corners = \[(0, 0), (w, 0), (w, h), (0, h)]\n to_corners = \[apply_homography(H, corner) for corner in from_corners]\n\n cornerpin = \{\n \"from1\": from_corners\[0], \"to1\": to_corners\[0],\n \"from2\": from_corners\[1], \"to2\": to_corners\[1],\n \"from3\": from_corners\[2], \"to3\": to_corners\[2],\n \"from4\": from_corners\[3], \"to4\": to_corners\[3],\n \}\n\n return cornerpin\n\n\n# =============================================================================\n# CAMERA TRACKER\n# =============================================================================\n\ndef extract_cameratracker_points():\n \"\"\"\n Parse the CameraTracker serializeKnob and return a list of tracks,\n each with 'track_index' and 'positions' (list of \{frame, x, y, weight\}).\n\n Ref: https://www.nukepedia.com/tools/python/import-export/export-cameratrackers-featuretracks/\n https://hagbarth.net/dissecting-the-nuke-cameratracker-node/\n \"\"\"\n\n s = cameratracker\['serializeKnob'].toScript()\n output = \[]\n count = 0\n track_index = 0\n\n dataItems = s.split('\\n')\n dataItems.pop(0)\n\n track_id = None\n\n for index, line in enumerate(dataItems):\n tmp = line.split(' ')\n if tmp\[1] == '0' and tmp\[2] == '1':\n count += 1\n\n elif tmp\[0] == str(count):\n try:\n subHeader = dataItems\[index + 1].split(' ')\n lifetime = int(subHeader\[6])\n startFrame = int(dataItems\[index - 1].split(' ')\[3])\n except (IndexError, ValueError):\n continue\n\n posList = \[]\n posList.append(\{\n \"frame\": startFrame,\n \"x\": tmp\[4],\n \"y\": tmp\[5],\n \"weight\": tmp\[1]\n \})\n\n for x in range(1, lifetime):\n items = dataItems\[index + 1 + x].split(' ')\n posList.append(\{\n \"frame\": startFrame + x,\n \"x\": items\[2],\n \"y\": items\[3],\n \"weight\": items\[1]\n \})\n\n output.append(\{\n \"track_index\": track_index,\n \"positions\": posList,\n \})\n\n track_index += 1\n track_id = str(tmp\[-3])\n\n elif tmp\[-1] == track_id:\n try:\n footnote = dataItems\[index - 1].split(' ')\n if footnote\[1] == '0' and footnote\[2] == '1':\n startFrame = int(footnote\[3])\n\n subHeader = dataItems\[index + 1].split(' ')\n posList = \[]\n frame = 1\n\n if len(subHeader) < 5:\n secondItem = dataItems\[index + 2].split(' ')\n lifetime = int(secondItem\[6])\n posList.append(\{\n \"frame\": startFrame,\n \"x\": tmp\[2],\n \"y\": tmp\[3],\n \"weight\": tmp\[1]\n \})\n if len(secondItem) == 13:\n posList.append(\{\n \"frame\": startFrame + 1,\n \"x\": secondItem\[2],\n \"y\": secondItem\[3],\n \"weight\": secondItem\[1]\n \})\n frame += 1\n for x in range(2, lifetime):\n items = dataItems\[index + 1 + x].split(' ')\n posList.append(\{\n \"frame\": startFrame + frame,\n \"x\": items\[2],\n \"y\": items\[3],\n \"weight\": items\[1]\n \})\n if len(items) == 9:\n frame += 1\n items = dataItems\[index + 2].split(' ')\n posList.append(\{\n \"frame\": startFrame + frame,\n \"x\": items\[2],\n \"y\": items\[3],\n \"weight\": items\[1]\n \})\n frame += 1\n else:\n lifetime = int(subHeader\[4])\n posList.append(\{\n \"frame\": startFrame,\n \"x\": tmp\[2],\n \"y\": tmp\[3],\n \"weight\": tmp\[1]\n \})\n for x in range(1, lifetime):\n items = dataItems\[index + 1 + x].split(' ')\n posList.append(\{\n \"frame\": startFrame + x,\n \"x\": items\[2],\n \"y\": items\[3],\n \"weight\": items\[1]\n \})\n\n output.append(\{\n \"track_index\": track_index,\n \"positions\": posList,\n \})\n\n track_index += 1\n except (IndexError, ValueError):\n continue\n\n return output\n\n\ndef filter_points(tracks):\n \"\"\"Return (points, weights) for tracks with positions on both frame 1 and 2.\n Weight per pair = min(w_frame1, w_frame2).\n \"\"\"\n\n result = \[]\n result_weights = \[]\n\n for track in tracks:\n frame1 = None\n frame2 = None\n\n for pos in track\[\"positions\"]:\n frame = pos\[\"frame\"]\n if frame == 1:\n frame1 = pos\n elif frame == 2:\n frame2 = pos\n\n if frame2 is None:\n continue\n\n if frame1:\n pos1 = (float(frame1\[\"x\"]), float(frame1\[\"y\"]))\n pos2 = (float(frame2\[\"x\"]), float(frame2\[\"y\"]))\n wt = min(float(frame1\[\"weight\"]), float(frame2\[\"weight\"]))\n result.append((pos1, pos2))\n result_weights.append(wt)\n\n return result, result_weights\n\n\ndef clear_cameratracker():\n \"\"\"Reset the CameraTracker serializeKnob to its empty default state.\"\"\"\n\n serializeKnob_default = \"22 serialization::archive 19 0 4 0 2 0 0 0 0 0 0 0 0 0 0 0 0 -1 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 1 0 0 0 0 0 0 0 0 0 0 0\"\n cameratracker\['serializeKnob'].fromScript(serializeKnob_default)\n return\n\n\ndef track_points():\n \"\"\"Run feature tracking and return (points, weights).\"\"\"\n\n clear_cameratracker()\n cameratracker\['trackFeatures'].execute()\n tracks = extract_cameratracker_points()\n points, weights = filter_points(tracks)\n clear_cameratracker()\n return points, weights\n\n\n# =============================================================================\n# OUTPUT NODES\n# =============================================================================\n\ndef create_out_node():\n \"\"\"Create and return the output Transform or CornerPin2D node,\n with optional surrounding Reformat nodes.\n \"\"\"\n\n with nuke.root():\n transform_type = node\['transform_type'].value()\n filtering = node\['points_filtering'].value()\n\n base_x = int(node\['xpos'].value()) + 120\n base_y = int(node\['ypos'].value()) + 40\n\n # Reformat-in: source -> common canvas (needed when ref > src)\n needs_reformat_in = (src_w != w or src_h != h)\n if needs_reformat_in:\n r_in = nuke.createNode('Reformat')\n r_in.setInput(0, None)\n r_in.setXpos(base_x)\n r_in.setYpos(base_y - 50)\n r_in\['type'].setValue('to box')\n r_in\['box_fixed'].setValue(1)\n r_in\['box_width'].setValue(w)\n r_in\['box_height'].setValue(h)\n r_in\['box_pixel_aspect'].setValue(src_par)\n r_in\['resize'].setValue('none')\n r_in\['center'].setValue(0)\n r_in\['black_outside'].setValue(1)\n r_in\['label'].setValue('MatchTransform\\n%dx%d' % (w, h))\n n_x, n_y = base_x, base_y\n\n if transform_type == 'Transform':\n n = nuke.createNode('Transform')\n matched = ' '.join(filter(None, \[\n 'T' if node\['match_translate'].value() == 1 else '',\n 'R' if node\['match_rotate'].value() == 1 else '',\n 'S' if node\['match_scale'].value() == 1 else '',\n 'Sk' if node\['match_skew'].value() == 1 else '',\n ]))\n n\['label'].setValue('MatchTransform\\nFiltering: %s \[%s]' % (filtering, matched))\n elif transform_type == 'CornerPin2D':\n n = nuke.createNode('CornerPin2D')\n n\['label'].setValue('MatchTransform\\nFiltering: %s' % filtering)\n\n n.setXpos(n_x)\n n.setYpos(n_y)\n\n if needs_reformat_in:\n n.setInput(0, r_in)\n else:\n n.setInput(0, None)\n\n set_out_node_animated(n, frames_count)\n\n # Reformat-out: common canvas -> reference (needed when src > ref)\n if w != ref_w or h != ref_h:\n r_out = nuke.createNode('Reformat')\n r_out.setInput(0, n)\n r_out.setXpos(n.xpos())\n r_out.setYpos(n.ypos() + 50)\n r_out\['type'].setValue('to box')\n r_out\['box_fixed'].setValue(1)\n r_out\['box_width'].setValue(ref_w)\n r_out\['box_height'].setValue(ref_h)\n r_out\['box_pixel_aspect'].setValue(ref_par)\n r_out\['resize'].setValue('none')\n r_out\['center'].setValue(0)\n r_out\['black_outside'].setValue(1)\n r_out\['label'].setValue('MatchTransform\\n%dx%d' % (ref_w, ref_h))\n\n return n\n\n\ndef set_out_node_animated(n, frames_count):\n \"\"\"Animate the relevant knobs of n (skipped for single-frame analysis).\"\"\"\n\n range_mode = node\['analysis_range'].value()\n if frames_count > 1 or range_mode in ('Input Clips Range', 'Custom Range'):\n\n knob = node\['transform_type']\n\n if knob.value() == 'Transform':\n if node\['match_translate'].value() == 1: n\['translate'].setAnimated()\n if node\['match_rotate'].value() == 1: n\['rotate'].setAnimated()\n if node\['match_scale'].value() == 1: n\['scale'].setAnimated()\n n\['center'].setAnimated()\n if node\['match_skew'].value() == 1:\n n\['skewX'].setAnimated()\n n\['skewY'].setAnimated()\n\n elif knob.value() == 'CornerPin2D':\n n\['to1'].setAnimated()\n n\['to2'].setAnimated()\n n\['to3'].setAnimated()\n n\['to4'].setAnimated()\n n\['from1'].setAnimated()\n n\['from2'].setAnimated()\n n\['from3'].setAnimated()\n n\['from4'].setAnimated()\n\n\ndef compute_match(points, weights=None):\n \"\"\"Compute transform or cornerpin data from point correspondences.\"\"\"\n knob = node\['transform_type']\n preset_name = node\['points_filtering'].value().lower()\n\n if knob.value() == 'Transform':\n return compute_transform(points, w, h, preset_name, weights=weights)\n elif knob.value() == 'CornerPin2D':\n return compute_cornerpin(points, w, h, preset_name, weights=weights)\n\n\ndef register_match(n, frame, data):\n \"\"\"Write a transform/cornerpin keyframe at the given frame.\"\"\"\n\n knob = node\['transform_type']\n\n if knob.value() == 'Transform':\n if node\['match_translate'].value() == 1:\n n\['translate'].setValueAt(data\['translate']\[0], frame, 0)\n n\['translate'].setValueAt(data\['translate']\[1], frame, 1)\n if node\['match_rotate'].value() == 1:\n n\['rotate'].setValueAt(data\['rotate'], frame)\n if node\['match_scale'].value() == 1:\n if isinstance(data\['scale'], tuple):\n n\['scale'].setValueAt(data\['scale']\[0], frame, 0)\n n\['scale'].setValueAt(data\['scale']\[1], frame, 1)\n else:\n n\['scale'].setValueAt(data\['scale'], frame)\n\n n\['center'].setValueAt(data\['center']\[0], frame, 0)\n n\['center'].setValueAt(data\['center']\[1], frame, 1)\n if node\['match_skew'].value() == 1:\n n\['skewX'].setValueAt(data\['skewX'], frame)\n n\['skewY'].setValueAt(data\['skewY'], frame)\n\n elif knob.value() == 'CornerPin2D':\n n\['to1'].setValueAt(data\['to1']\[0], frame, 0)\n n\['to1'].setValueAt(data\['to1']\[1], frame, 1)\n n\['to2'].setValueAt(data\['to2']\[0], frame, 0)\n n\['to2'].setValueAt(data\['to2']\[1], frame, 1)\n n\['to3'].setValueAt(data\['to3']\[0], frame, 0)\n n\['to3'].setValueAt(data\['to3']\[1], frame, 1)\n n\['to4'].setValueAt(data\['to4']\[0], frame, 0)\n n\['to4'].setValueAt(data\['to4']\[1], frame, 1)\n n\['from1'].setValueAt(data\['from1']\[0], frame, 0)\n n\['from1'].setValueAt(data\['from1']\[1], frame, 1)\n n\['from2'].setValueAt(data\['from2']\[0], frame, 0)\n n\['from2'].setValueAt(data\['from2']\[1], frame, 1)\n n\['from3'].setValueAt(data\['from3']\[0], frame, 0)\n n\['from3'].setValueAt(data\['from3']\[1], frame, 1)\n n\['from4'].setValueAt(data\['from4']\[0], frame, 0)\n n\['from4'].setValueAt(data\['from4']\[1], frame, 1)\n\n\n# =============================================================================\n# MAIN\n# =============================================================================\n\n# Deselect any selected node to prevent Transform from attaching to it\ntry:\n selectedNode = nuke.selectedNode()\n selectedNode.setSelected(False)\nexcept Exception:\n pass\n\nif is_nukex:\n first, last, frames_count = get_range()\n out_node = None\n\n\n switch\['which'].setValue(1) # For visualization during analysis\n\n with node:\n try:\n task = nuke.ProgressTask('Analysing...')\n step = 100.0 / frames_count\n progress = step\n count = 1\n\n for i in range(first, last + 1):\n if task.isCancelled():\n raise StopIteration()\n task.setMessage('Analysing Frame %s (%s of %s)' % (i, count, frames_count))\n\n framehold\['firstFrame'].setValue(i)\n\n try:\n points, weights = track_points()\n\n if validate_points(points):\n data = compute_match(points, weights)\n if not out_node:\n out_node = create_out_node()\n register_match(out_node, i, data)\n else:\n print('MatchTransform: Skipping frame %s — not enough features to match' % i)\n\n except ValueError as e:\n print('MatchTransform: Skipping frame %s — %s' % (i, e))\n except Exception as e:\n print('MatchTransform: Skipping frame %s — %s' % (i, e))\n\n task.setProgress(int(progress))\n progress += step\n count += 1\n\n if not out_node:\n nuke.alert('No frames in the input sequence could be matched.')\n\n except StopIteration:\n pass\n finally:\n del task\n switch\['which'].setValue(0)\nelse:\n nuke.alert('This gizmo is only available in NukeX.')\n"}
addUserKnob {20 about l About}
addUserKnob {26 _5 l "" +STARTLINE T "<b>MatchTransform</b><br>\n<i>Automatic Image Alignment for Nuke</i><br>\n<br>\n<b>Author:</b> Francesco Lorussi<br>\n<b>Version:</b> 1.0.0\n"}
}
Input {
inputs 0
name Reference
xpos -168
ypos -33
}
Reformat {
type "to box"
box_width {{"\[python \{max(nuke.toNode('Source').width(), nuke.toNode('Reference').width())\}]\n"}}
box_height {{"\[python \{max(nuke.toNode('Source').height(), nuke.toNode('Reference').height())\}]\n"}}
box_pixel_aspect {{input0.pixel_aspect}}
resize none
center false
filter impulse
black_outside true
name Reformat2
xpos -169
ypos 65
disable {{"\[python \{int(nuke.toNode('Reference').width() >= nuke.toNode('Source').width() and nuke.toNode('Reference').height() >= nuke.toNode('Source').height())\}]\n"}}
}
Crop {
box {0 0 {input.width} {input.height}}
reformat true
crop false
name Crop2
xpos -169
ypos 121
}
FrameHold {
firstFrame {{parent.FrameHold1.firstFrame}}
name FrameHold2
xpos -169
ypos 158
}
FrameRange {
first_frame 1
last_frame 1
time ""
name FrameRange2
xpos -169
ypos 203
}
Input {
inputs 0
name Source
xpos 0
ypos -35
number 1
}
set N8feba80 [stack 0]
Reformat {
type "to box"
box_width {{"\[python \{max(nuke.toNode('Source').width(), nuke.toNode('Reference').width())\}]\n"}}
box_height {{"\[python \{max(nuke.toNode('Source').height(), nuke.toNode('Reference').height())\}]\n"}}
box_pixel_aspect {{input0.pixel_aspect}}
resize none
center false
filter impulse
black_outside true
name Reformat1
xpos 0
ypos 62
disable {{"\[python \{int(nuke.toNode('Source').width() >= nuke.toNode('Reference').width() and nuke.toNode('Source').height() >= nuke.toNode('Reference').height())\}]\n"}}
}
Crop {
box {0 0 {input.width} {input.height}}
reformat true
crop false
name Crop1
xpos 0
ypos 115
}
FrameHold {
firstFrame 2
name FrameHold1
xpos 0
ypos 157
}
FrameRange {
first_frame 1
last_frame 1
time ""
name FrameRange1
xpos 0
ypos 204
}
AppendClip {
inputs 2
meta_from_first false
time ""
name AppendClip1
xpos -78
ypos 264
}
Dot {
name Dot2
xpos -44
ypos 304
}
set N9c2f800 [stack 0]
push $N8feba80
Dot {
name Dot3
xpos 239
ypos -32
}
Switch {
inputs 2
name Switch1
xpos 205
ypos 301
}
Output {
name Output1
xpos 205
ypos 389
}
push $N9c2f800
CameraTracker {
serializeKnob "22 serialization::archive 19 0 4 0 2 0 0 0 0 0 0 0 0 0 0 0 0 -1 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 1 0 0 0 0 0 0 0 0 0 0 0"
sourceType Stills
trackRange Custom
trackStart 1
trackStop 2
referenceFrames {{curve x1 1 2}}
fixedReferenceFrames {{curve x1 1 2}}
filenameSalt 23503
userTrackViews {left right}
trackCurves {"num tracks" {curve x1 2275 2275}
"track len - min" {curve x1 2 2}
"track len - avg" {curve x1 2 2}
"track len - max" {curve x1 2 2}
"Min Length" {curve x1 2 2}
"Solve Error" {curve x1 0 0}
"error - min" {curve}
"error - rms" {curve}
"error - track" {curve}
"error - max" {curve}
"Max Track Error" {curve x1 0 0}
"Max Error" {curve x1 0 0}}
minLengthThreshold 2
numberFeaturesForStills {{parent.number_features}}
featureThreshold 0.0001
refineFeatures true
minTrackLength 2
matchThreshold 0.0001
smoothnessThreshold 0.0001
consistencyThreshold 0.0001
cameraMotion "Planar Motion"
rootReferenceFrame 2
name CameraTracker1
xpos -78
ypos 375
}
end_group