Skip to content

Instantly share code, notes, and snippets.

@promto-c
Last active March 1, 2026 16:17
Show Gist options
  • Select an option

  • Save promto-c/8c1888828dde18eb28c29731164462ff to your computer and use it in GitHub Desktop.

Select an option

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.
"""
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