Last active
March 1, 2026 16:17
-
-
Save promto-c/8c1888828dde18eb28c29731164462ff to your computer and use it in GitHub Desktop.
Reference implementation for reparameterizing 3DE4 lens distortion coefficients across normalization or resolution changes, while preserving distortion invariance.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| MIT License | |
| Copyright (c) 2026 promto-c | |
| Permission is hereby granted, free of charge, to any person obtaining a copy | |
| of this software and associated documentation files (the "Software"), to deal | |
| in the Software without restriction, including without limitation the rights | |
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| copies of the Software, subject to the following conditions: | |
| The above copyright notice and this permission notice shall be included in | |
| all copies or substantial portions of the Software. | |
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. | |
| """ | |
| """ | |
| Non-Affiliation Notice | |
| This project is not affiliated with, endorsed by, or sponsored by | |
| 3DEqualizer or Science-D-Visions. | |
| 3DEqualizer is a trademark of its respective owner. This implementation is an | |
| independent mathematical interpretation based solely on publicly available | |
| documentation. | |
| """ | |
| """ | |
| 3DE4 Lens Reparameterization Utilities | |
| This module provides mathematical reparameterization of 3DEqualizer lens | |
| distortion models when normalization resolution changes. Polynomial | |
| coefficients are rescaled based on the normalization radius ratio so that | |
| distortion behavior remains identical under a new reference resolution. | |
| The implementation follows the standard 3DE4 lens distortion model | |
| definitions. | |
| Reference: | |
| 3DEqualizer Lens Distortion Model Documentation | |
| https://3dequalizer.com/doc/tde4_ldm_standard.pdf | |
| Notes: | |
| - This performs mathematical coefficient scaling only. | |
| - Lens geometry and distortion shape remain unchanged. | |
| - This does NOT simulate image resizing. It only rescales polynomial | |
| coefficients so the distortion field remains mathematically invariant. | |
| - Polynomial scaling follows radial order (r^2, r^4, r^6, ...). | |
| - Decentering (U/V) terms scale by one lower order: Degree-2 → s^1, Degree-4 → s^3. | |
| """ | |
| from dataclasses import dataclass | |
| from enum import StrEnum | |
| class FitMode(StrEnum): | |
| WIDTH = 'width' | |
| HEIGHT = 'height' | |
| @dataclass | |
| class LD3DE4Model: | |
| tde4_pixel_aspect: float = 1.0 | |
| tde4_filmback_width_cm: float = 0.0 | |
| tde4_filmback_height_cm: float = 0.0 | |
| @dataclass | |
| class LD3DE4RadialStandardDegree4(LD3DE4Model): | |
| Distortion_Degree_2: float = 0.0 | |
| U_Degree_2: float = 0.0 | |
| V_Degree_2: float = 0.0 | |
| Quartic_Distortion_Degree_4: float = 0.0 | |
| U_Degree_4: float = 0.0 | |
| V_Degree_4: float = 0.0 | |
| @dataclass | |
| class LD3DE4AnamorphicStandardDegree4(LD3DE4Model): | |
| Cx02_Degree_2: float = 0.0 | |
| Cy02_Degree_2: float = 0.0 | |
| Cx22_Degree_2: float = 0.0 | |
| Cy22_Degree_2: float = 0.0 | |
| Cx04_Degree_4: float = 0.0 | |
| Cy04_Degree_4: float = 0.0 | |
| Cx24_Degree_4: float = 0.0 | |
| Cy24_Degree_4: float = 0.0 | |
| Cx44_Degree_4: float = 0.0 | |
| Cy44_Degree_4: float = 0.0 | |
| @dataclass | |
| class LD3DE4AnamorphicStandardDegree6(LD3DE4AnamorphicStandardDegree4): | |
| Cx06_Degree_6: float = 0.0 | |
| Cy06_Degree_6: float = 0.0 | |
| Cx26_Degree_6: float = 0.0 | |
| Cy26_Degree_6: float = 0.0 | |
| Cx46_Degree_6: float = 0.0 | |
| Cy46_Degree_6: float = 0.0 | |
| Cx66_Degree_6: float = 0.0 | |
| Cy66_Degree_6: float = 0.0 | |
| class LD3DE4ModelUtil: | |
| @staticmethod | |
| def _compute_filmback_from_resolution( | |
| src_filmback_cm: tuple[float, float], | |
| src_resolution: tuple[float, float], | |
| dst_resolution: tuple[float, float], | |
| ) -> tuple[float, float]: | |
| """Compute new filmback from resolution ratio. | |
| Keeps physical cm-per-pixel ratio consistent. | |
| """ | |
| src_w_cm, src_h_cm = src_filmback_cm | |
| src_w_px, src_h_px = src_resolution | |
| dst_w_px, dst_h_px = dst_resolution | |
| cm_per_pixel_x = src_w_cm / src_w_px | |
| cm_per_pixel_y = src_h_cm / src_h_px | |
| # Safety check | |
| if abs(cm_per_pixel_x - cm_per_pixel_y) > 1e-9: | |
| print("Warning: Source filmback and resolution aspect mismatch.") | |
| new_w_cm = dst_w_px * cm_per_pixel_x | |
| new_h_cm = dst_h_px * cm_per_pixel_y | |
| return new_w_cm, new_h_cm | |
| @staticmethod | |
| def reparameterize_lens( | |
| lens: LD3DE4Model, | |
| src_resolution: tuple[float, float], | |
| dst_resolution: tuple[float, float], | |
| fit_mode: FitMode | None = None, | |
| filmback_as_resolution: bool = False, | |
| ) -> LD3DE4Model: | |
| """Reparameterize lens when normalization changes. | |
| Only mathematical scaling — no geometry change. | |
| Scaling rule: | |
| Let: | |
| s = r_new / r_old. | |
| Then polynomial coefficients scale as: | |
| k_n(new) = k_n(old) * s^n | |
| For decentering (U/V) terms: | |
| k_n(new) = k_n(old) * s^(n-1) | |
| Normalization behavior: | |
| The normalization radius depends on `fit_mode` and mirrors common | |
| "reformat to width / reformat to height" workflows. | |
| Let (W1, H1) be the source resolution and (W2, H2) the destination. | |
| - FitMode.WIDTH: | |
| Conceptually scale the source to match destination width while | |
| preserving aspect ratio, and use the diagonal of that fitted | |
| source as the old normalization reference: | |
| R_src^2 = (1 + (H1/W1)^2) * W2^2 | |
| R_dst^2 = W2^2 + H2^2 | |
| - FitMode.HEIGHT: | |
| Conceptually scale the source to match destination height while | |
| preserving aspect ratio, and use the diagonal of that fitted | |
| source as the old normalization reference: | |
| R_src^2 = (1 + (W1/H1)^2) * H2^2 | |
| R_dst^2 = W2^2 + H2^2 | |
| - None (diagonal normalization): | |
| Use each image diagonal directly: | |
| R_src^2 = W1^2 + H1^2 | |
| R_dst^2 = W2^2 + H2^2 | |
| The scaling factor used for coefficients is: | |
| s^2 = R_dst^2 / R_src^2 | |
| Args: | |
| lens: The input lens model to reparameterize. | |
| src_resolution: Source resolution as (width, height). | |
| Typically the plate resolution where the lens was originally | |
| solved or calibrated. | |
| dst_resolution: Target resolution as (width, height). | |
| The resolution where the lens model should behave identically. | |
| fit_mode: Optional normalization mode: | |
| - FitMode.WIDTH: treat as "reformat to width" | |
| - FitMode.HEIGHT: treat as "reformat to height" | |
| - None: normalize using diagonal (default) | |
| filmback_as_resolution: If True, use `dst_resolution` directly as | |
| filmback. If False, compute filmback from source filmback and | |
| resolution ratio (default). | |
| Returns: | |
| A new lens model instance with reparameterized coefficients adapted | |
| to the target normalization resolution. | |
| Raises: | |
| TypeError: If the lens model type is not supported. | |
| """ | |
| # Determine filmback for the new lens model. | |
| if filmback_as_resolution: | |
| filmback_width, filmback_height = dst_resolution | |
| else: | |
| filmback_width, filmback_height = LD3DE4ModelUtil._compute_filmback_from_resolution( | |
| (lens.tde4_filmback_width_cm, lens.tde4_filmback_height_cm), | |
| src_resolution, | |
| dst_resolution, | |
| ) | |
| # Compute squared normalization radius for the old reference. | |
| src_width, src_height = src_resolution | |
| dst_width, dst_height = dst_resolution | |
| dst_width_sq = dst_width * dst_width | |
| dst_height_sq = dst_height * dst_height | |
| if fit_mode == FitMode.WIDTH: | |
| inv_aspect = (src_height / src_width) / lens.tde4_pixel_aspect | |
| src_norm_radius_sq = (1.0 + inv_aspect * inv_aspect) * dst_width_sq | |
| dst_norm_radius_sq = dst_width_sq + dst_height_sq | |
| elif fit_mode == FitMode.HEIGHT: | |
| aspect = (src_width / src_height) * lens.tde4_pixel_aspect | |
| src_norm_radius_sq = (1.0 + aspect * aspect) * dst_height_sq | |
| dst_norm_radius_sq = dst_width_sq + dst_height_sq | |
| else: | |
| par_sq = lens.tde4_pixel_aspect * lens.tde4_pixel_aspect | |
| src_width_sq = src_width * src_width | |
| src_height_sq = src_height * src_height | |
| src_norm_radius_sq = (src_width_sq * par_sq) + src_height_sq | |
| dst_norm_radius_sq = (dst_width_sq * par_sq) + dst_height_sq | |
| # Destination normalization is always the destination diagonal here. | |
| radius_ratio_pow2 = dst_norm_radius_sq / src_norm_radius_sq | |
| radius_ratio_pow4 = radius_ratio_pow2 * radius_ratio_pow2 | |
| # Coefficient scaling follows the polynomial order. | |
| # degree-2 terms scale by s^2, | |
| # degree-4 terms scale by s^4, etc. | |
| # Decentering terms scale by one lower order: | |
| # degree-2 decentering terms scale by s^1, | |
| # degree-4 decentering terms scale by s^3, etc. | |
| if isinstance(lens, LD3DE4RadialStandardDegree4): | |
| radius_ratio = radius_ratio_pow2 ** 0.5 | |
| radius_ratio_pow3 = radius_ratio_pow2 * radius_ratio | |
| return LD3DE4RadialStandardDegree4( | |
| tde4_filmback_width_cm=filmback_width, | |
| tde4_filmback_height_cm=filmback_height, | |
| Distortion_Degree_2=lens.Distortion_Degree_2 * radius_ratio_pow2, | |
| U_Degree_2=lens.U_Degree_2 * radius_ratio, | |
| V_Degree_2=lens.V_Degree_2 * radius_ratio, | |
| Quartic_Distortion_Degree_4=lens.Quartic_Distortion_Degree_4 * radius_ratio_pow4, | |
| U_Degree_4=lens.U_Degree_4 * radius_ratio_pow3, | |
| V_Degree_4=lens.V_Degree_4 * radius_ratio_pow3, | |
| ) | |
| # Degree6 first (subclass of Degree4) | |
| if isinstance(lens, LD3DE4AnamorphicStandardDegree6): | |
| radius_ratio_pow6 = radius_ratio_pow2 * radius_ratio_pow2 * radius_ratio_pow2 | |
| return LD3DE4AnamorphicStandardDegree6( | |
| tde4_filmback_width_cm=filmback_width, | |
| tde4_filmback_height_cm=filmback_height, | |
| Cx02_Degree_2=lens.Cx02_Degree_2 * radius_ratio_pow2, | |
| Cy02_Degree_2=lens.Cy02_Degree_2 * radius_ratio_pow2, | |
| Cx22_Degree_2=lens.Cx22_Degree_2 * radius_ratio_pow2, | |
| Cy22_Degree_2=lens.Cy22_Degree_2 * radius_ratio_pow2, | |
| Cx04_Degree_4=lens.Cx04_Degree_4 * radius_ratio_pow4, | |
| Cy04_Degree_4=lens.Cy04_Degree_4 * radius_ratio_pow4, | |
| Cx24_Degree_4=lens.Cx24_Degree_4 * radius_ratio_pow4, | |
| Cy24_Degree_4=lens.Cy24_Degree_4 * radius_ratio_pow4, | |
| Cx44_Degree_4=lens.Cx44_Degree_4 * radius_ratio_pow4, | |
| Cy44_Degree_4=lens.Cy44_Degree_4 * radius_ratio_pow4, | |
| Cx06_Degree_6=lens.Cx06_Degree_6 * radius_ratio_pow6, | |
| Cy06_Degree_6=lens.Cy06_Degree_6 * radius_ratio_pow6, | |
| Cx26_Degree_6=lens.Cx26_Degree_6 * radius_ratio_pow6, | |
| Cy26_Degree_6=lens.Cy26_Degree_6 * radius_ratio_pow6, | |
| Cx46_Degree_6=lens.Cx46_Degree_6 * radius_ratio_pow6, | |
| Cy46_Degree_6=lens.Cy46_Degree_6 * radius_ratio_pow6, | |
| Cx66_Degree_6=lens.Cx66_Degree_6 * radius_ratio_pow6, | |
| Cy66_Degree_6=lens.Cy66_Degree_6 * radius_ratio_pow6, | |
| ) | |
| if isinstance(lens, LD3DE4AnamorphicStandardDegree4): | |
| return LD3DE4AnamorphicStandardDegree4( | |
| tde4_filmback_width_cm=filmback_width, | |
| tde4_filmback_height_cm=filmback_height, | |
| Cx02_Degree_2=lens.Cx02_Degree_2 * radius_ratio_pow2, | |
| Cy02_Degree_2=lens.Cy02_Degree_2 * radius_ratio_pow2, | |
| Cx22_Degree_2=lens.Cx22_Degree_2 * radius_ratio_pow2, | |
| Cy22_Degree_2=lens.Cy22_Degree_2 * radius_ratio_pow2, | |
| Cx04_Degree_4=lens.Cx04_Degree_4 * radius_ratio_pow4, | |
| Cy04_Degree_4=lens.Cy04_Degree_4 * radius_ratio_pow4, | |
| Cx24_Degree_4=lens.Cx24_Degree_4 * radius_ratio_pow4, | |
| Cy24_Degree_4=lens.Cy24_Degree_4 * radius_ratio_pow4, | |
| Cx44_Degree_4=lens.Cx44_Degree_4 * radius_ratio_pow4, | |
| Cy44_Degree_4=lens.Cy44_Degree_4 * radius_ratio_pow4, | |
| ) | |
| raise TypeError(f"Unsupported lens model type: {type(lens).__name__}") | |
| def main(): | |
| from pprint import pprint | |
| # Real-world-ish example context: | |
| # - You solved lens distortion on a 4.5K Open Gate plate | |
| # - Later you need to apply the same lens model on a 4K UHD deliverable | |
| # - Here we "reparameterize" coefficients so the distortion result matches | |
| # under the new normalization reference (fit_mode selects width/height/diag) | |
| # Example 1: Radial Standard Degree4 (typical small coefficients) | |
| lens_radial = LD3DE4RadialStandardDegree4( | |
| tde4_filmback_width_cm=2.496, # 24.96 mm | |
| tde4_filmback_height_cm=1.404, # 14.04 mm | |
| Distortion_Degree_2=-0.0152, | |
| U_Degree_2=0.00035, | |
| V_Degree_2=-0.00022, | |
| Quartic_Distortion_Degree_4=0.00110, | |
| U_Degree_4=-0.00006, | |
| V_Degree_4=0.00004, | |
| ) | |
| # Plate resolution changes (pixels) | |
| # 4.5K Open Gate -> 4K UHD | |
| src_resolution = (4608.0, 3164.0) | |
| dst_resolution = (3840.0, 2160.0) | |
| lens_radial_new = LD3DE4ModelUtil.reparameterize_lens( | |
| lens=lens_radial, | |
| src_resolution=src_resolution, | |
| dst_resolution=dst_resolution, | |
| fit_mode=FitMode.WIDTH, # common for "normalize by width" workflows | |
| ) | |
| print("Radial old:") | |
| pprint(lens_radial) | |
| print("Radial new (reparameterized for 4K UHD, fit width):") | |
| pprint(lens_radial_new) | |
| # Example 2: Anamorphic Degree6 (values kept small; degree-6 terms usually tiny) | |
| lens_ana6 = LD3DE4AnamorphicStandardDegree6( | |
| tde4_filmback_width_cm=2.496, | |
| tde4_filmback_height_cm=1.404, | |
| Cx02_Degree_2=0.0120, | |
| Cy02_Degree_2=-0.0085, | |
| Cx22_Degree_2=0.0015, | |
| Cy22_Degree_2=-0.0011, | |
| Cx04_Degree_4=0.0012, | |
| Cy04_Degree_4=-0.0009, | |
| Cx24_Degree_4=0.00025, | |
| Cy24_Degree_4=-0.00018, | |
| Cx44_Degree_4=0.00006, | |
| Cy44_Degree_4=-0.00005, | |
| Cx06_Degree_6=0.00008, | |
| Cy06_Degree_6=-0.00006, | |
| Cx26_Degree_6=0.00002, | |
| Cy26_Degree_6=-0.000015, | |
| Cx46_Degree_6=0.000005, | |
| Cy46_Degree_6=-0.000004, | |
| Cx66_Degree_6=0.0000012, | |
| Cy66_Degree_6=-0.0000010, | |
| ) | |
| # A 3.2K "extraction" or plate resize -> a 2K deliverable | |
| src_resolution_ana = (3200.0, 1800.0) | |
| dst_resolution_ana = (2048.0, 1152.0) | |
| lens_ana6_new = LD3DE4ModelUtil.reparameterize_lens( | |
| lens=lens_ana6, | |
| src_resolution=src_resolution_ana, | |
| dst_resolution=dst_resolution_ana, | |
| fit_mode=None, # diagonal (common when you want uniform behavior) | |
| ) | |
| print("Ana6 old:") | |
| pprint(lens_ana6) | |
| print("Ana6 new (reparameterized for 2K, diagonal):") | |
| pprint(lens_ana6_new) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment