diff --git a/build/build_nuitka.py b/build/build_nuitka.py
index 23249bf..6b20ed2 100644
--- a/build/build_nuitka.py
+++ b/build/build_nuitka.py
@@ -20,7 +20,7 @@ from paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repo_path
from pyproject_utils import bundle_items, extras_require, get_version, install_requires
from verify import verify_built_wheel
from wheel_build import publish_wheel_to_sdk, run_bdist_wheel, write_wheel_setup
-from wheel_layout import copy_bundle_items, package_data_from_bundle
+from wheel_layout import copy_bundle_items, copy_orisys_blend, package_data_from_bundle
def _reset_dir(path) -> None:
@@ -51,6 +51,10 @@ def build() -> None:
ext_suffix = copy_compiled_orisys(pkg_dir, ext_file)
+ wheel_root = NUITKA_WHEEL_DIR / "wheel_root"
+ blend_count = copy_orisys_blend(REPO_ROOT, wheel_root)
+ print(f"Staged orisys_blend Python modules: {blend_count}")
+
# Bundle rules live in pyproject.toml → [[tool.orisys.bundle.items]]
bundle = bundle_items(REPO_ROOT)
copied_counts = copy_bundle_items(REPO_ROOT, pkg_dir, bundle)
diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py
index cd87e0d..0217987 100644
--- a/build/nuitka_compile.py
+++ b/build/nuitka_compile.py
@@ -19,6 +19,7 @@ NUITKA_INCLUDE_PACKAGES = (
)
NUITKA_NOFOLLOW_IMPORTS = (
+ "orisys_blend",
"cupy",
"cupyx",
"pyopencl",
diff --git a/build/wheel_build.py b/build/wheel_build.py
index e23edaa..0fd1f33 100644
--- a/build/wheel_build.py
+++ b/build/wheel_build.py
@@ -31,7 +31,7 @@ setup(
version="{version}",
description="Orisys - Optical Tactile Sensor SDK (Nuitka Compiled)",
author="Orisys",
- packages=['orisys', 'orisys.examples'],
+ packages=['orisys', 'orisys.examples', 'orisys_blend'],
package_dir={{'': 'wheel_root'}},
package_data={package_data},
include_package_data=True,
diff --git a/build/wheel_layout.py b/build/wheel_layout.py
index 8409458..66549e6 100644
--- a/build/wheel_layout.py
+++ b/build/wheel_layout.py
@@ -83,9 +83,22 @@ def package_data_from_bundle(ext_suffix: str, items: list[dict]) -> dict[str, li
package_data: dict[str, list[str]] = {
"orisys": [f"*{ext_suffix}"],
"orisys.examples": ["*.py", "README.md", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"],
+ "orisys_blend": ["*.py"],
}
for item in items:
if item["name"] == "examples":
continue
package_data["orisys"].extend(package_data_paths_for_item(item))
return package_data
+
+
+def copy_orisys_blend(repo_root: Path, wheel_root: Path) -> int:
+ """Copy pure-Python orisys_blend (Numba kernels) next to the Nuitka orisys .pyd."""
+ src = repo_root / "src" / "orisys_blend"
+ dst = wheel_root / "orisys_blend"
+ if dst.exists():
+ shutil.rmtree(dst)
+ shutil.copytree(src, dst)
+ count = sum(1 for p in dst.rglob("*.py") if p.is_file())
+ print(f"Copied orisys_blend ({count} .py files) to {dst}")
+ return count
diff --git a/config/ddjx01.json b/config/ddjx01.json
index 21b5e96..af88413 100644
--- a/config/ddjx01.json
+++ b/config/ddjx01.json
@@ -18,7 +18,9 @@
"nominal_ov_pos": 100,
"n": [3,3],
"row_tol": 0.6,
- "box_size": [250, 250]
+ "box_size": [250, 250],
+ "blend":"numba",
+ "blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
diff --git a/config/ddjx01_480p.json b/config/ddjx01_480p.json
index 6083c90..e58f998 100644
--- a/config/ddjx01_480p.json
+++ b/config/ddjx01_480p.json
@@ -18,7 +18,9 @@
"nominal_ov_pos": 44,
"n": [3,3],
"row_tol": 0.6,
- "box_size": [98, 98]
+ "box_size": [98, 98],
+ "blend":"numpy",
+ "blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
diff --git a/config/ddjx01_q6a.json b/config/ddjx01_q6a.json
index f267045..d6a1335 100644
--- a/config/ddjx01_q6a.json
+++ b/config/ddjx01_q6a.json
@@ -18,7 +18,9 @@
"nominal_ov_pos": 67,
"n": [3,3],
"row_tol": 0.6,
- "box_size": [180, 180]
+ "box_size": [180, 180],
+ "blend":"numpy",
+ "blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
diff --git a/config/ddjx01_wuxi.json b/config/ddjx01_wuxi.json
index 0f0a874..cbbe434 100644
--- a/config/ddjx01_wuxi.json
+++ b/config/ddjx01_wuxi.json
@@ -18,7 +18,9 @@
"nominal_ov_pos": 67,
"n": [3,3],
"row_tol": 0.6,
- "box_size": [180, 180]
+ "box_size": [180, 180],
+ "blend":"numpy",
+ "blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
diff --git a/config/prototype_of3.json b/config/prototype_of3.json
deleted file mode 100644
index 8d94471..0000000
--- a/config/prototype_of3.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "name": "prototype",
- "version": "1.0",
- "camera": {
- "resolution_raw": [1920, 1080],
- "fps": 30,
- "units": 15,
- "units_used": 9,
- "resolution_stitch": [365, 365],
- "stitch_res_list": [365,263,123,63]
- },
- "stitcher": {
- "min_area_frac": 0.01,
- "max_area_frac": 0.04,
- "max_aspect_ratio": 100,
- "index": [1,2,3,6,7,8,11,12,13],
- "margin": 10,
- "nominal_ov_pos": 100,
- "n": [3,3],
- "row_tol": 0.6,
- "box_size": [220, 220]
- },
- "tracker": {
- "adaptive": true,
- "lower_threshold": 12.0,
- "upper_threshold": 18.0,
- "loop_closure_interval": 1,
- "algorithm": "dis",
- "dis_preset": "medium",
- "dis_mode": 3,
- "algorithm_list": ["raft", "dis"],
- "hhd_downsample": 3
- },
- "contact": {
- "min_contour_area": 3000,
- "flow_threshold":1,
- "div_threshold":0.02,
- "expansion_threshold": 0.01,
- "kernel_size": [5,5],
- "denoise_sigma": 15
- }
-}
-
-
diff --git a/config/prototype_of3_480p.json b/config/prototype_of3_480p.json
deleted file mode 100644
index 5c321a3..0000000
--- a/config/prototype_of3_480p.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "name": "prototype",
- "version": "1.0",
- "camera": {
- "resolution_raw": [854, 480],
- "fps": 30,
- "units": 15,
- "units_used": 9,
- "resolution_stitch": [123, 123],
- "stitch_res_list": [365,263,123,63]
- },
- "stitcher": {
- "min_area_frac": 0.01,
- "max_area_frac": 0.06,
- "max_aspect_ratio": 100,
- "index": [1,2,3,6,7,8,11,12,13],
- "margin": 4,
- "nominal_ov_pos": 44,
- "n": [3,3],
- "row_tol": 0.6,
- "box_size": [98, 98]
- },
- "tracker": {
- "adaptive": true,
- "lower_threshold": 12.0,
- "upper_threshold": 18.0,
- "loop_closure_interval": 1,
- "algorithm": "dis",
- "dis_preset": "medium",
- "dis_mode": 2,
- "algorithm_list": ["raft", "dis"],
- "hhd_downsample": 3
- },
- "contact": {
- "min_contour_area": 3000,
- "flow_threshold":1,
- "div_threshold":0.02,
- "expansion_threshold": 0.01,
- "kernel_size": [5,5],
- "denoise_sigma": 6
- }
-}
-
-
diff --git a/config/prototype_of3_720p.json b/config/prototype_of3_720p.json
deleted file mode 100644
index 185ccbf..0000000
--- a/config/prototype_of3_720p.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "name": "prototype",
- "version": "1.0",
- "camera": {
- "resolution_raw": [1280, 720],
- "fps": 30,
- "units": 15,
- "units_used": 9,
- "resolution_stitch": [263, 263],
- "stitch_res_list": [365,263,123,63]
- },
- "stitcher": {
- "min_area_frac": 0.01,
- "max_area_frac": 0.04,
- "max_aspect_ratio": 25,
- "index": [1,2,3,6,7,8,11,12,13],
- "margin": 10,
- "nominal_ov_pos": 67,
- "n": [3,3],
- "row_tol": 0.6,
- "box_size": [180, 180]
- },
- "tracker": {
- "adaptive": true,
- "lower_threshold": 12.0,
- "upper_threshold": 18.0,
- "loop_closure_interval": 1,
- "algorithm": "dis",
- "dis_preset": "medium",
- "dis_mode": 2,
- "algorithm_list": ["raft", "dis"],
- "hhd_downsample": 3
- },
- "contact": {
- "min_contour_area": 3000,
- "flow_threshold":1,
- "div_threshold":0.02,
- "expansion_threshold": 0.01,
- "kernel_size": [5,5],
- "denoise_sigma": 10
- }
-}
-
-
diff --git a/config/prototype_raft.json b/config/prototype_raft.json
deleted file mode 100644
index 480995b..0000000
--- a/config/prototype_raft.json
+++ /dev/null
@@ -1,43 +0,0 @@
-{
- "name": "prototype",
- "version": "1.0",
- "camera": {
- "resolution_raw": [1920, 1080],
- "fps": 30,
- "units": 15,
- "units_used": 9,
- "resolution_stitch": [384, 384]
- },
- "stitcher": {
- "min_area_frac": 0.01,
- "max_area_frac": 0.04,
- "max_aspect_ratio": 100,
- "index": [1,2,3,6,7,8,11,12,13],
- "margin": 10,
- "nominal_ov_pos": 100,
- "n": [3,3],
- "row_tol": 0.6,
- "box_size": [220, 220]
- },
- "tracker": {
- "adaptive": true,
- "lower_threshold": 12.0,
- "upper_threshold": 18.0,
- "loop_closure_interval": 1,
- "algorithm": "raft",
- "dis_preset": "medium",
- "dis_mode": 3,
- "algorithm_list": ["raft", "dis"],
- "hhd_downsample": 3
- },
- "contact": {
- "min_contour_area": 3000,
- "flow_threshold":1.5,
- "div_threshold":0.08,
- "expansion_threshold": 0.01,
- "kernel_size": [5,5],
- "denoise_sigma": 15
- }
-}
-
-
diff --git a/pyproject.toml b/pyproject.toml
index c627639..226ad5f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,7 @@ cuda13 = ["cupy-cuda13x>=13.0.0"]
# Backward compatibility: 'cuda' defaults to CUDA 12.x
cuda = ["cupy-cuda12x>=12.3.0"]
gui = ["pyqt5>=5.15", "pyqtgraph>=0.13"]
+numba = ["numba>=0.59.0"]
[tool.setuptools]
package-dir = { "" = "src" }
diff --git a/src/orisys/Stitcher.py b/src/orisys/Stitcher.py
index b4d1068..02a2719 100644
--- a/src/orisys/Stitcher.py
+++ b/src/orisys/Stitcher.py
@@ -5,45 +5,92 @@ from scipy import ndimage
import cv2
try:
- from numba import njit
+ from orisys_blend.kernel import _NUMBA_AVAILABLE, blend_kernel as _blend_kernel_numba
+except Exception:
+ _NUMBA_AVAILABLE = False
+ _blend_kernel_numba = None
- _NUMBA_AVAILABLE = True
-except ImportError:
+# Re-export availability from the pure-Python kernel package when import succeeded
+# but numba itself was missing (blend_kernel is None).
+if _blend_kernel_numba is None:
_NUMBA_AVAILABLE = False
- def njit(*args, **kwargs):
- def decorator(fn):
- return fn
- return decorator
+def _blend_kernel_numpy(blend_patch, wsum_patch, cov_patch, src_patch, w_patch):
+ """Incremental weighted-average composite (pure NumPy, no JIT).
+
+ Uses contiguous arithmetic instead of boolean fancy indexing. Callers must
+ zero ``blend`` / ``wsum`` on uncovered pixels (as the stitcher reset does);
+ then the covered lerp also paints first-touch pixels correctly when ``w > 0``.
+ """
+ w = w_patch if w_patch.dtype == np.float32 else np.asarray(w_patch, dtype=np.float32)
+ src = src_patch if src_patch.dtype == np.float32 else np.asarray(src_patch, dtype=np.float32)
+
+ uncovered = cov_patch == 0
+ if uncovered.all():
+ # First paint over a cleared patch: direct copy.
+ np.copyto(blend_patch, src)
+ np.copyto(wsum_patch, w)
+ cov_patch.fill(1)
+ return
+
+ # Contiguous lerp. With zeroed uncovered pixels this also equals a first paint
+ # when w > 0 (a ≈ 1 → blend becomes src).
+ ws = wsum_patch + w
+ a = w / np.maximum(ws, np.float32(1e-6))
+ # Channel loop keeps temps HxW instead of one HxWx3 gather/scatter.
+ for c in range(src.shape[2]):
+ bc = blend_patch[..., c]
+ bc += (src[..., c] - bc) * a
+ np.copyto(wsum_patch, ws)
+
+ # Match numba on rare first-touch pixels with ~0 weight (lerp would leave 0).
+ if uncovered.any():
+ need_fix = uncovered & (w < np.float32(1e-6))
+ if need_fix.any():
+ blend_patch[need_fix] = src[need_fix]
+ wsum_patch[need_fix] = w[need_fix]
+
+ cov_patch.fill(1)
-@njit(cache=False)
-def _blend_kernel(blend_patch, wsum_patch, cov_patch, src_patch, w_patch):
- """Fused incremental weighted-average composite (numba-accelerated)."""
- sh = src_patch.shape[0]
- sw = src_patch.shape[1]
- for y in range(sh):
- for x in range(sw):
- w = w_patch[y, x]
- s0 = np.float32(src_patch[y, x, 0])
- s1 = np.float32(src_patch[y, x, 1])
- s2 = np.float32(src_patch[y, x, 2])
- if cov_patch[y, x] == 0:
- blend_patch[y, x, 0] = s0
- blend_patch[y, x, 1] = s1
- blend_patch[y, x, 2] = s2
- wsum_patch[y, x] = w
- else:
- ws = wsum_patch[y, x] + w
- if ws < 1e-6:
- ws = 1e-6
- a = w / ws
- blend_patch[y, x, 0] += (s0 - blend_patch[y, x, 0]) * a
- blend_patch[y, x, 1] += (s1 - blend_patch[y, x, 1]) * a
- blend_patch[y, x, 2] += (s2 - blend_patch[y, x, 2]) * a
- wsum_patch[y, x] = ws
- cov_patch[y, x] = 1
+# Backward-compatible alias used by older call sites / tests.
+_blend_kernel = _blend_kernel_numpy
+
+# JSON ``stitcher.blend`` / ``blend_list``: none = fast paste; numpy/numba = feathered.
+_BLEND_MODES = frozenset({"none", "numpy", "numba"})
+_DEFAULT_BLEND_MODE = "none"
+
+
+def _resolve_blend_mode(config_stitcher=None):
+ """Resolve stitcher blend mode from env, then JSON ``blend``.
+
+ Returns ``(mode, fell_back_from_numba)`` where mode is one of
+ ``none`` / ``numpy`` / ``numba``.
+
+ Priority: ``ORISYS_STITCH_BLEND``, ``ORISYS_STITCH_BLEND_BACKEND``,
+ then ``config_stitcher["blend"]`` / legacy ``blend_backend``, then ``none``.
+ """
+ requested = None
+ for key in ("ORISYS_STITCH_BLEND", "ORISYS_STITCH_BLEND_BACKEND"):
+ env = os.environ.get(key, "").strip().lower()
+ if env in _BLEND_MODES:
+ requested = env
+ break
+
+ if requested is None and isinstance(config_stitcher, dict):
+ for key in ("blend", "blend_backend"):
+ cfg = str(config_stitcher.get(key, "")).strip().lower()
+ if cfg in _BLEND_MODES:
+ requested = cfg
+ break
+
+ if requested is None:
+ requested = _DEFAULT_BLEND_MODE
+
+ if requested == "numba" and not _NUMBA_AVAILABLE:
+ return "numpy", True
+ return requested, False
class Stitcher:
@@ -85,25 +132,100 @@ class Stitcher:
self._feature_stitch_print_every = 30
self._feature_stitch_pad = 5
self._feature_stitch_match_brightness = False
+ self._blend_mode, fell_back = _resolve_blend_mode(config_stitcher)
+ # Feather path uses numpy/numba; ``none`` keeps a numpy fallback if forced on.
+ self._blend_backend = "numpy" if self._blend_mode == "none" else self._blend_mode
+ self._numba_failed = False
- if self.isstitch and not _NUMBA_AVAILABLE:
+ if fell_back and self.isstitch:
print(
- "Warning: numba is not installed; feature stitching will run a slow "
- "Python blend path (~1 FPS). Install numba for real-time performance."
+ "Warning: stitcher blend=numba requested but numba is "
+ "unavailable; using numpy blend path."
)
+ elif self.isstitch and self.verbose:
+ print(f"Stitch blend: {self._blend_mode}")
if self.verbose:
print(f"Stitcher initialized with config: {self.config_stitcher}")
- def configure_feature_stitch(self, *, match_brightness=None, pad=None, print_every=None):
- """Update runtime options for feature-based stitching."""
+ @property
+ def blend_mode(self):
+ """Configured blend mode: ``none``, ``numpy``, or ``numba``."""
+ return self._blend_mode
+
+ def configure_feature_stitch(
+ self,
+ *,
+ match_brightness=None,
+ pad=None,
+ print_every=None,
+ blend=None,
+ blend_backend=None,
+ ):
+ """Update runtime options for feature-based stitching.
+
+ ``blend`` accepts ``none`` / ``numpy`` / ``numba`` (same as JSON ``stitcher.blend``).
+ ``blend_backend`` is a legacy alias for the same values.
+ """
if match_brightness is not None:
self._feature_stitch_match_brightness = bool(match_brightness)
if pad is not None:
self._feature_stitch_pad = max(0, int(pad))
if print_every is not None:
self._feature_stitch_print_every = max(1, int(print_every))
-
+ mode = blend if blend is not None else blend_backend
+ if mode is not None:
+ mode = str(mode).strip().lower()
+ if mode not in _BLEND_MODES:
+ raise ValueError(
+ f"Unknown blend={mode!r}; expected one of {sorted(_BLEND_MODES)}"
+ )
+ if mode == "numba" and (not _NUMBA_AVAILABLE or self._numba_failed):
+ print("Warning: numba unavailable; using numpy blend.")
+ mode = "numpy"
+ self._blend_mode = mode
+ self._blend_backend = "numpy" if mode == "none" else mode
+ if mode == "numba":
+ self._numba_failed = False
+
+ def _resolve_call_blend(self, blend):
+ """Map a ``stitch_rt_optimized(blend=...)`` arg to ``(use_feather, backend)``."""
+ if blend is None:
+ mode = self._blend_mode
+ elif isinstance(blend, bool):
+ if blend:
+ mode = self._blend_mode if self._blend_mode != "none" else "numpy"
+ else:
+ return False, self._blend_backend
+ else:
+ mode = str(blend).strip().lower()
+ if mode not in _BLEND_MODES:
+ raise ValueError(
+ f"Unknown blend={mode!r}; expected one of {sorted(_BLEND_MODES)}"
+ )
+ if mode == "numba" and not _NUMBA_AVAILABLE:
+ mode = "numpy"
+
+ if mode == "numba" and self._numba_failed:
+ mode = "numpy"
+
+ if mode == "none":
+ return False, self._blend_backend
+ return True, mode
+
+ def _mark_numba_failed(self, exc):
+ """Stick to numpy after a Numba JIT/runtime failure (log once)."""
+ if self._numba_failed:
+ return
+ self._numba_failed = True
+ self._blend_backend = "numpy"
+ if self._blend_mode == "numba":
+ self._blend_mode = "numpy"
+ print(
+ f"Warning: numba blend failed ({type(exc).__name__}: {exc}); "
+ f"falling back to numpy."
+ )
+
def _get_blend_coef(self, ov_pos, dim, ndim):
key = (ov_pos, dim, ndim)
coef = self._blend_coef_cache.get(key)
@@ -117,13 +239,30 @@ class Stitcher:
self._blend_coef_cache[key] = blend_coef
return blend_coef
- def stitch_rt_optimized(self, change_sv=False, s_mean=28., v_mean=180., blend=True):
- feature_stitched = self.stitch_feature_optimized(
- blend=blend,
- match_brightness=self._feature_stitch_match_brightness,
- pad=self._feature_stitch_pad,
- print_every=self._feature_stitch_print_every,
- )
+ def stitch_rt_optimized(self, change_sv=False, s_mean=28., v_mean=180., blend=None):
+ """Stitch the current tile list.
+
+ ``blend`` overrides JSON/env for this call:
+ - ``None`` (default): use configured ``stitcher.blend``
+ - ``True`` / ``False``: force feathered blend on/off
+ - ``\"none\"`` / ``\"numpy\"`` / ``\"numba\"``: force that mode for this call
+ """
+ use_feather, blend_backend = self._resolve_call_blend(blend)
+ prev_backend = self._blend_backend
+ self._blend_backend = blend_backend
+ try:
+ feature_stitched = self.stitch_feature_optimized(
+ blend=use_feather,
+ match_brightness=self._feature_stitch_match_brightness,
+ pad=self._feature_stitch_pad,
+ print_every=self._feature_stitch_print_every,
+ )
+ finally:
+ # Keep sticky numpy fallback after a Numba failure; do not restore numba.
+ if self._numba_failed:
+ self._blend_backend = "numpy"
+ else:
+ self._blend_backend = prev_backend
if feature_stitched is not None:
return feature_stitched
@@ -455,7 +594,7 @@ class Stitcher:
return wy[:, None] * wx[None, :]
def _blend_tile_inplace(self, blend, wsum, cov, src, weight, y0, x0, scratch=None):
- """Composite a tile onto the canvas using numba-fused weighted average."""
+ """Composite a tile onto the canvas using weighted average blending."""
h, w = src.shape[:2]
H, W = blend.shape[:2]
y0d, x0d = max(0, y0), max(0, x0)
@@ -467,13 +606,20 @@ class Stitcher:
sh, sw = y1d - y0d, x1d - x0d
src_f = src[sy0:sy0+sh, sx0:sx0+sw].astype(np.float32, copy=False)
- _blend_kernel(
+ args = (
blend[y0d:y1d, x0d:x1d],
wsum[y0d:y1d, x0d:x1d],
cov[y0d:y1d, x0d:x1d],
src_f,
weight[sy0:sy0+sh, sx0:sx0+sw],
)
+ if self._blend_backend == "numba" and _blend_kernel_numba is not None:
+ try:
+ _blend_kernel_numba(*args)
+ return
+ except Exception as exc:
+ self._mark_numba_failed(exc)
+ _blend_kernel_numpy(*args)
def _mean_gray_bgr(self, bgr):
ch = cv2.mean(bgr)
@@ -535,6 +681,214 @@ class Stitcher:
shapes = tuple(tb.shape[:2] for tb in tiles_bgr_c)
return ref_h, ref_w, shapes, pad
+ def _ensure_feature_geom_cache(
+ self,
+ canvas_cache,
+ t_ref_bgr,
+ tiles_bgr_c,
+ H_use_list,
+ pad,
+ *,
+ allocate_blend_bufs=True,
+ ):
+ """Populate/reuse fixed-window geometry in canvas_cache. Returns geom_cache_hit."""
+ h0c, w0c = t_ref_bgr.shape[:2]
+ geom_key = self._geom_cache_key(t_ref_bgr, tiles_bgr_c, pad)
+ geom_cache_hit = canvas_cache.get("geom_key") == geom_key
+ if geom_cache_hit:
+ if allocate_blend_bufs and (
+ "blend_buf" not in canvas_cache
+ or "wsum_buf" not in canvas_cache
+ or "cov_buf" not in canvas_cache
+ ):
+ out_h = canvas_cache["out_h"]
+ out_w = canvas_cache["out_w"]
+ canvas_cache["blend_buf"] = np.zeros((out_h, out_w, 3), dtype=np.float32)
+ canvas_cache["wsum_buf"] = np.zeros((out_h, out_w), dtype=np.float32)
+ canvas_cache["cov_buf"] = np.zeros((out_h, out_w), dtype=np.uint8)
+ return True
+
+ ref_corners = np.array([[0, 0], [w0c, 0], [w0c, h0c], [0, h0c]], dtype=np.float64)
+ pts = [ref_corners]
+ for tile_bgr, H_use in zip(tiles_bgr_c, H_use_list):
+ if H_use is None:
+ continue
+ hi, wi = tile_bgr.shape[:2]
+ tx, ty = self._translation_from_H_to_ref(H_use)
+ pts.append(
+ np.array(
+ [[tx, ty], [tx + wi, ty], [tx + wi, ty + hi], [tx, ty + hi]],
+ dtype=np.float64,
+ )
+ )
+
+ all_xy = np.concatenate(pts, axis=0)
+ x_min_f = float(all_xy[:, 0].min()) - pad
+ y_min_f = float(all_xy[:, 1].min()) - pad
+ x_max_f = float(all_xy[:, 0].max()) + pad
+ y_max_f = float(all_xy[:, 1].max()) + pad
+ out_w = max(1, int(np.ceil(x_max_f - x_min_f)))
+ out_h = max(1, int(np.ceil(y_max_f - y_min_f)))
+ ref_y0 = int(np.floor(-y_min_f))
+ ref_x0 = int(np.floor(-x_min_f))
+
+ pastes = []
+ for H_use in H_use_list:
+ if H_use is None:
+ pastes.append(None)
+ continue
+ tx, ty = self._translation_from_H_to_ref(H_use)
+ pastes.append((int(np.floor(ty - y_min_f)), int(np.floor(tx - x_min_f))))
+
+ _dr_y0 = max(0, ref_y0)
+ _dr_x0 = max(0, ref_x0)
+ _dr_y1 = min(out_h, ref_y0 + h0c)
+ _dr_x1 = min(out_w, ref_x0 + w0c)
+ for _tb, _pa in zip(tiles_bgr_c, pastes):
+ if _pa is None:
+ continue
+ _py, _px = _pa
+ _th, _tw = _tb.shape[:2]
+ _dr_y0 = min(_dr_y0, max(0, _py))
+ _dr_x0 = min(_dr_x0, max(0, _px))
+ _dr_y1 = max(_dr_y1, min(out_h, _py + _th))
+ _dr_x1 = max(_dr_x1, min(out_w, _px + _tw))
+
+ canvas_cache["geom_key"] = geom_key
+ canvas_cache["out_w"] = out_w
+ canvas_cache["out_h"] = out_h
+ canvas_cache["ref_y0"] = ref_y0
+ canvas_cache["ref_x0"] = ref_x0
+ canvas_cache["pastes"] = pastes
+ canvas_cache["x_min_f"] = x_min_f
+ canvas_cache["y_min_f"] = y_min_f
+ canvas_cache["crop_rect"] = None
+ canvas_cache["dirty_rect"] = (_dr_y0, _dr_y1, _dr_x0, _dr_x1)
+ canvas_cache["weight_maps"] = {}
+ canvas_cache["uint8_buf"] = np.zeros((out_h, out_w, 3), dtype=np.uint8)
+ if allocate_blend_bufs:
+ canvas_cache["blend_buf"] = np.zeros((out_h, out_w, 3), dtype=np.float32)
+ canvas_cache["wsum_buf"] = np.zeros((out_h, out_w), dtype=np.float32)
+ canvas_cache["cov_buf"] = np.zeros((out_h, out_w), dtype=np.uint8)
+ else:
+ # Coverage buffer only needed once to compute crop_rect for paste path.
+ canvas_cache["cov_buf"] = np.zeros((out_h, out_w), dtype=np.uint8)
+ canvas_cache.pop("blend_buf", None)
+ canvas_cache.pop("wsum_buf", None)
+ return False
+
+ @staticmethod
+ def _paste_tile_clipped(canvas, tile, y0, x0, cov=None):
+ """Paste tile into canvas with clipping; optionally mark coverage."""
+ th, tw = tile.shape[:2]
+ H, W = canvas.shape[:2]
+ y0d, x0d = max(0, y0), max(0, x0)
+ y1d, x1d = min(H, y0 + th), min(W, x0 + tw)
+ if y0d >= y1d or x0d >= x1d:
+ return
+ sy0, sx0 = y0d - y0, x0d - x0
+ sh, sw = y1d - y0d, x1d - x0d
+ np.copyto(canvas[y0d:y1d, x0d:x1d], tile[sy0:sy0 + sh, sx0:sx0 + sw])
+ if cov is not None:
+ cov[y0d:y1d, x0d:x1d] = 1
+
+ def _paste_feature_tiles_fast(
+ self,
+ t_ref_bgr,
+ tiles_bgr_c,
+ H_use_list,
+ canvas_cache,
+ *,
+ geom_cache_hit,
+ frame_idx=0,
+ print_every=30,
+ verbose=False,
+ t_profile0=0.0,
+ t_after_brightness=0.0,
+ t_after_geom=0.0,
+ ):
+ """Fixed-geometry uint8 paste path used when blend=False."""
+ do_profile = verbose and frame_idx % print_every == 0
+ out_w = canvas_cache["out_w"]
+ out_h = canvas_cache["out_h"]
+ ref_y0 = canvas_cache["ref_y0"]
+ ref_x0 = canvas_cache["ref_x0"]
+ pastes = canvas_cache["pastes"]
+
+ canvas = canvas_cache.get("uint8_buf")
+ if canvas is None or canvas.shape[0] != out_h or canvas.shape[1] != out_w:
+ canvas = np.zeros((out_h, out_w, 3), dtype=np.uint8)
+ canvas_cache["uint8_buf"] = canvas
+
+ dirty = canvas_cache.get("dirty_rect")
+ if geom_cache_hit and dirty is not None:
+ dy0, dy1, dx0, dx1 = dirty
+ canvas[dy0:dy1, dx0:dx1] = 0
+ elif not geom_cache_hit:
+ canvas.fill(0)
+ else:
+ canvas.fill(0)
+ t_after_reset = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
+
+ need_crop = canvas_cache.get("crop_rect") is None
+ cov = canvas_cache.get("cov_buf") if need_crop else None
+ if need_crop:
+ if cov is None or cov.shape != (out_h, out_w):
+ cov = np.zeros((out_h, out_w), dtype=np.uint8)
+ canvas_cache["cov_buf"] = cov
+ else:
+ if dirty is not None:
+ dy0, dy1, dx0, dx1 = dirty
+ cov[dy0:dy1, dx0:dx1] = 0
+ else:
+ cov.fill(0)
+
+ pasted_tiles = 0
+ for tile_bgr, H_use, paste in zip(tiles_bgr_c, H_use_list, pastes):
+ if H_use is None or paste is None:
+ continue
+ y0, x0 = paste
+ self._paste_tile_clipped(canvas, tile_bgr, y0, x0, cov=cov)
+ pasted_tiles += 1
+ self._paste_tile_clipped(canvas, t_ref_bgr, ref_y0, ref_x0, cov=cov)
+ t_after_paste = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
+
+ crop_rect = canvas_cache.get("crop_rect")
+ if crop_rect is None and cov is not None:
+ crop_rect = self._compute_final_crop_rect(cov)
+ canvas_cache["crop_rect"] = crop_rect
+ if crop_rect is not None:
+ y0, y1, x0, x1 = crop_rect
+ roi = canvas[y0:y1, x0:x1]
+ else:
+ roi = canvas
+
+ rh, rw = roi.shape[:2]
+ stitch_buf = canvas_cache.get("stitch_buf")
+ if stitch_buf is None or stitch_buf.shape[0] != rh or stitch_buf.shape[1] != rw:
+ stitch_buf = np.empty((rh, rw, 3), dtype=np.uint8)
+ canvas_cache["stitch_buf"] = stitch_buf
+ np.copyto(stitch_buf, roi)
+ t_after_finalize = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
+
+ if do_profile:
+ xmf = canvas_cache.get("x_min_f", 0.0)
+ ymf = canvas_cache.get("y_min_f", 0.0)
+ print(
+ f"stitch_feature_optimized: canvas out_w={out_w}, out_h={out_h}, "
+ f"x_min={xmf:.2f}, y_min={ymf:.2f} (fast_paste)"
+ )
+ print(
+ "stitch_feature_optimized steps: "
+ f"brightness={t_after_brightness - t_profile0:.6f}s, "
+ f"geom={t_after_geom - t_after_brightness:.6f}s ({'hit' if geom_cache_hit else 'miss'}), "
+ f"reset={t_after_reset - t_after_geom:.6f}s, "
+ f"paste={t_after_paste - t_after_reset:.6f}s ({pasted_tiles + 1} tiles incl ref), "
+ f"finalize={t_after_finalize - t_after_paste:.6f}s, "
+ f"total={t_after_finalize - t_profile0:.6f}s"
+ )
+ return stitch_buf
+
def _render_feature_tiles(
self,
t_ref_bgr,
@@ -579,77 +933,37 @@ class Stitcher:
tiles_bgr_c = tiles_bgr
t_after_brightness = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
- geom_key = self._geom_cache_key(t_ref_bgr, tiles_bgr_c, pad)
- geom_cache_hit = canvas_cache.get("geom_key") == geom_key
- if not geom_cache_hit:
- ref_corners = np.array([[0, 0], [w0c, 0], [w0c, h0c], [0, h0c]], dtype=np.float64)
- pts = [ref_corners]
- for tile_bgr, H_use in zip(tiles_bgr_c, H_use_list):
- if H_use is None:
- continue
- hi, wi = tile_bgr.shape[:2]
- tx, ty = self._translation_from_H_to_ref(H_use)
- pts.append(
- np.array(
- [[tx, ty], [tx + wi, ty], [tx + wi, ty + hi], [tx, ty + hi]],
- dtype=np.float64,
- )
- )
-
- all_xy = np.concatenate(pts, axis=0)
- x_min_f = float(all_xy[:, 0].min()) - pad
- y_min_f = float(all_xy[:, 1].min()) - pad
- x_max_f = float(all_xy[:, 0].max()) + pad
- y_max_f = float(all_xy[:, 1].max()) + pad
- out_w = max(1, int(np.ceil(x_max_f - x_min_f)))
- out_h = max(1, int(np.ceil(y_max_f - y_min_f)))
- ref_y0 = int(np.floor(-y_min_f))
- ref_x0 = int(np.floor(-x_min_f))
-
- pastes = []
- for H_use in H_use_list:
- if H_use is None:
- pastes.append(None)
- continue
- tx, ty = self._translation_from_H_to_ref(H_use)
- pastes.append((int(np.floor(ty - y_min_f)), int(np.floor(tx - x_min_f))))
-
- _dr_y0 = max(0, ref_y0)
- _dr_x0 = max(0, ref_x0)
- _dr_y1 = min(out_h, ref_y0 + h0c)
- _dr_x1 = min(out_w, ref_x0 + w0c)
- for _tb, _pa in zip(tiles_bgr_c, pastes):
- if _pa is None:
- continue
- _py, _px = _pa
- _th, _tw = _tb.shape[:2]
- _dr_y0 = min(_dr_y0, max(0, _py))
- _dr_x0 = min(_dr_x0, max(0, _px))
- _dr_y1 = max(_dr_y1, min(out_h, _py + _th))
- _dr_x1 = max(_dr_x1, min(out_w, _px + _tw))
-
- canvas_cache["geom_key"] = geom_key
- canvas_cache["out_w"] = out_w
- canvas_cache["out_h"] = out_h
- canvas_cache["ref_y0"] = ref_y0
- canvas_cache["ref_x0"] = ref_x0
- canvas_cache["pastes"] = pastes
- canvas_cache["x_min_f"] = x_min_f
- canvas_cache["y_min_f"] = y_min_f
- canvas_cache["crop_rect"] = None
- canvas_cache["dirty_rect"] = (_dr_y0, _dr_y1, _dr_x0, _dr_x1)
- canvas_cache["blend_buf"] = np.zeros((out_h, out_w, 3), dtype=np.float32)
- canvas_cache["wsum_buf"] = np.zeros((out_h, out_w), dtype=np.float32)
- canvas_cache["cov_buf"] = np.zeros((out_h, out_w), dtype=np.uint8)
- canvas_cache["weight_maps"] = {}
- else:
- out_w = canvas_cache["out_w"]
- out_h = canvas_cache["out_h"]
- ref_y0 = canvas_cache["ref_y0"]
- ref_x0 = canvas_cache["ref_x0"]
- pastes = canvas_cache["pastes"]
+ geom_cache_hit = self._ensure_feature_geom_cache(
+ canvas_cache,
+ t_ref_bgr,
+ tiles_bgr_c,
+ H_use_list,
+ pad,
+ allocate_blend_bufs=bool(blend),
+ )
t_after_geom = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
+ if not blend:
+ return self._paste_feature_tiles_fast(
+ t_ref_bgr,
+ tiles_bgr_c,
+ H_use_list,
+ canvas_cache,
+ geom_cache_hit=geom_cache_hit,
+ frame_idx=frame_idx,
+ print_every=print_every,
+ verbose=verbose,
+ t_profile0=t_profile0,
+ t_after_brightness=t_after_brightness,
+ t_after_geom=t_after_geom,
+ )
+
+ out_w = canvas_cache["out_w"]
+ out_h = canvas_cache["out_h"]
+ ref_y0 = canvas_cache["ref_y0"]
+ ref_x0 = canvas_cache["ref_x0"]
+ pastes = canvas_cache["pastes"]
+
blend_buf = canvas_cache["blend_buf"]
wsum = canvas_cache["wsum_buf"]
cov = canvas_cache["cov_buf"]
@@ -667,7 +981,7 @@ class Stitcher:
cov.fill(0)
t_after_reset = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
- blend_margin = 16 if blend else 0
+ blend_margin = 16
blended_tiles = 0
for tile_bgr, H_use, paste in zip(tiles_bgr_c, H_use_list, pastes):
diff --git a/src/orisys/frame_processors.py b/src/orisys/frame_processors.py
index 331ce1e..3b7e946 100644
--- a/src/orisys/frame_processors.py
+++ b/src/orisys/frame_processors.py
@@ -16,7 +16,7 @@ class CompoundEyeFrameProcessor:
def process(self, frame):
self.stitcher.get_stitch_unit(frame)
self.stitcher.extract_tiles()
- return self.stitcher.stitch_rt_optimized(blend=False)
+ return self.stitcher.stitch_rt_optimized()
class SingleCameraFrameProcessor:
diff --git a/src/orisys_blend/__init__.py b/src/orisys_blend/__init__.py
new file mode 100644
index 0000000..3b05a79
--- /dev/null
+++ b/src/orisys_blend/__init__.py
@@ -0,0 +1,10 @@
+"""Pure-Python Numba stitch kernels kept outside the Nuitka-compiled orisys .pyd.
+
+Nuitka cannot reliably JIT ``@njit`` functions baked into a compiled extension.
+This package is shipped as source next to the Nuitka wheel so Numba can see
+real CPython bytecode at runtime.
+"""
+
+from .kernel import blend_kernel
+
+__all__ = ["blend_kernel"]
diff --git a/src/orisys_blend/kernel.py b/src/orisys_blend/kernel.py
new file mode 100644
index 0000000..8c2ef0d
--- /dev/null
+++ b/src/orisys_blend/kernel.py
@@ -0,0 +1,51 @@
+"""Fused stitch blend kernel (Numba JIT when available)."""
+
+from __future__ import annotations
+
+import numpy as np
+
+try:
+ from numba import njit
+
+ _NUMBA_AVAILABLE = True
+except ImportError:
+ _NUMBA_AVAILABLE = False
+
+ def njit(*args, **kwargs):
+ def decorator(fn):
+ return fn
+
+ return decorator
+
+
+if _NUMBA_AVAILABLE:
+
+ @njit(cache=True)
+ def blend_kernel(blend_patch, wsum_patch, cov_patch, src_patch, w_patch):
+ """Fused incremental weighted-average composite (numba-accelerated)."""
+ sh = src_patch.shape[0]
+ sw = src_patch.shape[1]
+ for y in range(sh):
+ for x in range(sw):
+ w = w_patch[y, x]
+ s0 = np.float32(src_patch[y, x, 0])
+ s1 = np.float32(src_patch[y, x, 1])
+ s2 = np.float32(src_patch[y, x, 2])
+ if cov_patch[y, x] == 0:
+ blend_patch[y, x, 0] = s0
+ blend_patch[y, x, 1] = s1
+ blend_patch[y, x, 2] = s2
+ wsum_patch[y, x] = w
+ else:
+ ws = wsum_patch[y, x] + w
+ if ws < 1e-6:
+ ws = 1e-6
+ a = w / ws
+ blend_patch[y, x, 0] += (s0 - blend_patch[y, x, 0]) * a
+ blend_patch[y, x, 1] += (s1 - blend_patch[y, x, 1]) * a
+ blend_patch[y, x, 2] += (s2 - blend_patch[y, x, 2]) * a
+ wsum_patch[y, x] = ws
+ cov_patch[y, x] = 1
+
+else:
+ blend_kernel = None
diff --git a/tools/3d_patch_generation_blender/Readme.md b/tools/3d_patch_generation_blender/Readme.md
deleted file mode 100644
index 7698b9b..0000000
--- a/tools/3d_patch_generation_blender/Readme.md
+++ /dev/null
@@ -1,121 +0,0 @@
-# 3D Patch Generation Blender 工具
-
-本文件夹包含 Blender 场景文件以及辅助脚本,用于导出网格体/相机数据,并将一张采集到的 2D 图像重新映射回 3D 表面。
-
-## 主要文件
-
-- `dd02c_v2.blend`
- DD02C 曲面与相机配置所使用的主要 Blender 工程文件。
-
-- `blender_export.py`
- 在 Blender 中运行,用于导出以下数据:
- - 网格几何信息
- - 与 UV 相关的 loop 数据
- - 相机内参
- - 相机位姿
- - 预计算的像素到 3D 查找表
-
-- `mapto3d.py`
- 读取导出的数据和一张采集图像,并生成带颜色的 PLY 格式 3D 点云。
-
-## 导出 / 生成的数据
-
-- `exported_data/`
- 由 `blender_export.py` 生成。通常应包含以下文件:
- - `mesh.npz`
- - `camera_intrinsics.npz`
- - `world_to_cam.npy`
- - `pixel_to_xyz.npz`
-
-- `captured_image.png`
- 要映射回导出 3D 表面的输入图像。当前该图像由 Blender 渲染得到。
-
-- `mapped_points.ply`
- 由 `mapto3d.py` 生成的输出点云文件。
-
-- `texture/`
- 保存实验过程中生成的纹理图片。
-
-## 工作流程
-
-### 1. 准备 Blender 场景
-
-在 Blender 中打开 `dd02c_v2.blend`。
-
-请确认:
-- 已选中目标网格
-- 相机存在且位置正确
-- 渲染分辨率与采集图像分辨率一致
-
-### 2. 导出几何和相机数据
-
-在 Blender 的里面上面转到“scripts” 模式,然后点击运行 `blender_export.py`。
-
-该脚本会将所需数据导出到:
-
-```text
-exported_data/
-```
-
-脚本会写出以下内容:
-- 网格顶点和面信息
-- UV loop 数据
-- OpenCV 风格的相机内参
-- world-to-camera 变换矩阵
-- 可选的逐像素 3D 查找表
-
-### 3. 准备输入图像
-
-将需要投影到 3D 表面的图像放到当前文件夹中,可以在Blender 渲染生成,例如:
-
-```text
-captured_image.png
-```
-
-`mapto3d.py` 目前在其 `__main__` 代码块中直接指定输入图像路径。
-
-### 4. 将图像像素映射到 3D
-
-运行:
-
-```bash
-python mapto3d.py
-```
-
-该脚本会:
-- 加载 Blender 导出的数据
-- 加载输入图像
-- 在需要时对图像做上下翻转,以匹配导出的像素映射顺序
-- 优先使用预计算的 `pixel_to_xyz.npz`
-- 将结果保存为 `mapped_points.ply`
-
-如果缺少预计算查找表,脚本会退回到逐像素射线求交,运行速度会慢很多。
-
-### 5. 查看输出结果
-
-生成的点云可以使用支持 PLY 的工具打开,例如:
-- Open3D
-- MeshLab
-- CloudCompare
-
-`mapto3d.py` 中也包含可选的 Open3D 可视化步骤。
-
-## 说明
-
-- `mapto3d.py` 目前假设导出的相机模型与输入图像分辨率一致。
-- 脚本现在默认会对加载的图像做上下翻转,以保证映射颜色与 Blender 导出的像素顺序一致。
-- 为了获得更好的性能,建议在 `exported_data/` 中保留 `pixel_to_xyz.npz`。
-
-## 依赖
-
-### Blender 侧
-
-- Blender Python API:`bpy`
-- `numpy`
-
-### Python 侧
-
-- `numpy`
-- `opencv-python`
-- `open3d`(可选,用于可视化)
-
diff --git a/tools/3d_patch_generation_blender/blender_export.py b/tools/3d_patch_generation_blender/blender_export.py
deleted file mode 100644
index 2f4d2c2..0000000
--- a/tools/3d_patch_generation_blender/blender_export.py
+++ /dev/null
@@ -1,147 +0,0 @@
-import bpy
-import numpy as np
-import json
-import os
-from mathutils import Matrix, Vector
-
-def export_camera_intrinsics(camera_obj, scene):
- """Return camera intrinsic matrix K (3x3) and distortion (none)."""
- cam = camera_obj.data
- render = scene.render
- res_x = render.resolution_x
- res_y = render.resolution_y
-
- # Sensor size and focal length
- if cam.sensor_fit == 'HORIZONTAL':
- sensor_width = cam.sensor_width
- sensor_height = sensor_width / (res_x / res_y)
- else:
- sensor_height = cam.sensor_height
- sensor_width = sensor_height * (res_x / res_y)
- focal = cam.lens # in mm
-
- # Pixel sizes in mm
- pixel_width = sensor_width / res_x
- pixel_height = sensor_height / res_y
-
- # Intrinsic matrix (OpenCV style: fx, fy in pixels, cx, cy principal point)
- fx = focal / pixel_width
- fy = focal / pixel_height
- cx = res_x / 2.0
- cy = res_y / 2.0
-
- K = np.array([[fx, 0, cx],
- [0, fy, cy],
- [0, 0, 1]], dtype=np.float64)
- return K, res_x, res_y
-
-def export_mesh_data(obj, filepath):
- """Export mesh vertices, faces, and UVs to a compressed .npz file."""
- me = obj.data
- # Transform vertices to world space
- vertices = np.array([obj.matrix_world @ v.co for v in me.vertices], dtype=np.float32)
- faces = np.array([poly.vertices[:] for poly in me.polygons], dtype=np.int32)
-
- # UVs (per loop, need mapping to vertices for simplicity we'll store per-face)
- uv_layer = me.uv_layers.active
- if uv_layer:
- uv_data = np.array([uv_layer.data[i].uv for i in range(len(uv_layer.data))], dtype=np.float32)
- loop_to_vertex = np.array([me.loops[i].vertex_index for i in range(len(me.loops))], dtype=np.int32)
- else:
- uv_data = np.zeros((0,2), dtype=np.float32)
- loop_to_vertex = np.zeros(0, dtype=np.int32)
-
- np.savez_compressed(filepath,
- vertices=vertices,
- faces=faces,
- uv_data=uv_data,
- loop_to_vertex=loop_to_vertex)
- print(f"Mesh exported to {filepath}")
-
-def export_camera_pose(camera_obj, filepath):
- """Export camera extrinsics (world-to-camera transform matrix)."""
- # Camera world matrix: transforms from camera local to world
- cam_to_world = np.array(camera_obj.matrix_world, dtype=np.float32)
- # We need world-to-camera transform (inverse)
- world_to_cam = np.linalg.inv(cam_to_world)
- np.save(filepath, world_to_cam)
- print(f"Camera pose exported to {filepath}")
-
-def export_pixel_to_3d_lookup(obj, camera_obj, scene, filepath):
- """
- Precompute for every pixel the 3D hit point (world coordinates)
- and save as a 2D array (height, width, 3). This avoids raycasting later.
- """
- depsgraph = bpy.context.evaluated_depsgraph_get()
- render = scene.render
- res_x = render.resolution_x
- res_y = render.resolution_y
-
- # Get camera data
- cam = camera_obj.data
- if cam.sensor_fit == 'HORIZONTAL':
- sensor_width = cam.sensor_width
- sensor_height = sensor_width / (res_x / res_y)
- else:
- sensor_height = cam.sensor_height
- sensor_width = sensor_height * (res_x / res_y)
- focal = cam.lens
- frame_width = sensor_width / focal
- frame_height = sensor_height / focal
-
- # Preallocate array (height, width, 3) for XYZ world coordinates
- xyz_map = np.full((res_y, res_x, 3), np.nan, dtype=np.float32)
-
- origin = camera_obj.matrix_world.translation
- cam_rot = camera_obj.matrix_world.to_quaternion()
-
- for y in range(res_y):
- # Normalized sensor coordinate v (0=bottom, 1=top)
- v_ndc = (y + 0.5) / res_y
- sy = (v_ndc - 0.5) * frame_height
- for x in range(res_x):
- u_ndc = (x + 0.5) / res_x
- sx = (u_ndc - 0.5) * frame_width
-
- direction_cam = Vector((sx, sy, -1.0))
- direction_world = cam_rot @ direction_cam
- direction_world.normalize()
-
- hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
- depsgraph, origin, direction_world)
- if hit and hit_obj == obj:
- xyz_map[y, x, :] = location
-
- np.savez_compressed(filepath, xyz_map=xyz_map)
- print(f"Pixel-to-3D lookup saved to {filepath} (shape {res_y}x{res_x})")
-
-def export_all():
- # Configuration
- output_dir = bpy.path.abspath("//exported_data/")
- os.makedirs(output_dir, exist_ok=True)
-
- obj = bpy.context.active_object # or specify name
- cam = bpy.data.objects.get("Camera")
- if not cam:
- cam = bpy.context.scene.camera
- if not obj or not cam:
- raise RuntimeError("Select mesh and ensure camera exists.")
-
- # 1. Mesh data
- export_mesh_data(obj, os.path.join(output_dir, "mesh.npz"))
-
- # 2. Camera intrinsics (K matrix) and resolution
- K, w, h = export_camera_intrinsics(cam, bpy.context.scene)
- np.savez(os.path.join(output_dir, "camera_intrinsics.npz"), K=K, width=w, height=h)
-
- # 3. Camera pose (world-to-camera)
- export_camera_pose(cam, os.path.join(output_dir, "world_to_cam.npy"))
-
- # 4. Optional: precomputed pixel-to-3D map (fastest)
- export_pixel_to_3d_lookup(obj, cam, bpy.context.scene,
- os.path.join(output_dir, "pixel_to_xyz.npz"))
-
- print("Export complete.")
-
-if __name__ == "__main__":
- export_all()
\ No newline at end of file
diff --git a/tools/3d_patch_generation_blender/captured_image.png b/tools/3d_patch_generation_blender/captured_image.png
deleted file mode 100644
index cb97cc9..0000000
Binary files a/tools/3d_patch_generation_blender/captured_image.png and /dev/null differ
diff --git a/tools/3d_patch_generation_blender/checker_patch_dd02c.py b/tools/3d_patch_generation_blender/checker_patch_dd02c.py
deleted file mode 100644
index f8d0aa8..0000000
--- a/tools/3d_patch_generation_blender/checker_patch_dd02c.py
+++ /dev/null
@@ -1,585 +0,0 @@
-"""
-Texture Generator for Arbitrary 3D Surfaces (Blender Script)
-- Mode: "patches" (black/white checker patches) or "dots" (circular markers)
-- Mesh UVs are filled white so that non‑visible parts become white.
-- Texture background (no‑mesh areas) stays black.
-"""
-
-import bpy
-import numpy as np
-import os
-import time
-from mathutils import Vector
-
-# ----------------------------------------------------------------------
-# Common Utilities
-# ----------------------------------------------------------------------
-def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
- """
- Draw a cross (plus sign) in pixel space.
- cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left.
- length_px: total length of each arm (pixels).
- thickness_px: line thickness (odd number gives symmetric result).
- """
- # Convert UV centre to pixel coordinates (y from bottom)
- cx = int(round(cx_uv * (width - 1)))
- cy = int(round(cy_uv * (height - 1)))
-
- half_len = length_px // 2
- half_thick = thickness_px // 2
-
- # Horizontal arm
- x0 = max(0, cx - half_len)
- x1 = min(width - 1, cx + half_len)
- y0 = max(0, cy - half_thick)
- y1 = min(height - 1, cy + half_thick)
- for y in range(y0, y1 + 1):
- for x in range(x0, x1 + 1):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
- # Vertical arm
- x0 = max(0, cx - half_thick)
- x1 = min(width - 1, cx + half_thick)
- y0 = max(0, cy - half_len)
- y1 = min(height - 1, cy + half_len)
- for y in range(y0, y1 + 1):
- for x in range(x0, x1 + 1):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
-def fill_background(pixels, width, height, color):
- """Fill the entire image with a solid RGBA color (0-1 floats)."""
- r, g, b, a = color
- for y in range(height):
- for x in range(width):
- idx = (y * width + x) * 4
- pixels[idx] = r
- pixels[idx+1] = g
- pixels[idx+2] = b
- pixels[idx+3] = a
-
-def fill_polygon(image, pixels, width, height, polygon, color):
- """Fill a polygon in the pixel array (flattened, RGBA)."""
- poly = [(int(round(x)), int(round(y))) for x, y in polygon]
- if len(poly) < 3:
- return
- min_x = max(0, min(p[0] for p in poly))
- max_x = min(width-1, max(p[0] for p in poly))
- min_y = max(0, min(p[1] for p in poly))
- max_y = min(height-1, max(p[1] for p in poly))
- for y in range(min_y, max_y + 1):
- intersections = []
- for i in range(len(poly)):
- x1, y1 = poly[i]
- x2, y2 = poly[(i+1) % len(poly)]
- if y1 == y2:
- continue
- if (y1 <= y < y2) or (y2 <= y < y1):
- t = (y - y1) / (y2 - y1)
- x = x1 + t * (x2 - x1)
- intersections.append(x)
- intersections.sort()
- for k in range(0, len(intersections), 2):
- if k+1 >= len(intersections):
- break
- x_start = int(round(intersections[k]))
- x_end = int(round(intersections[k+1]))
- x_start = max(0, min(width-1, x_start))
- x_end = max(0, min(width-1, x_end))
- for x in range(x_start, x_end + 1):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
-
-def fill_polygon_value(grid, width, height, polygon, value):
- """Fill a polygon into a 2D scalar grid."""
- poly = [(int(round(x)), int(round(y))) for x, y in polygon]
- if len(poly) < 3:
- return
- min_x = max(0, min(p[0] for p in poly))
- max_x = min(width - 1, max(p[0] for p in poly))
- min_y = max(0, min(p[1] for p in poly))
- max_y = min(height - 1, max(p[1] for p in poly))
- for y in range(min_y, max_y + 1):
- intersections = []
- for i in range(len(poly)):
- x1, y1 = poly[i]
- x2, y2 = poly[(i + 1) % len(poly)]
- if y1 == y2:
- continue
- if (y1 <= y < y2) or (y2 <= y < y1):
- t = (y - y1) / (y2 - y1)
- x = x1 + t * (x2 - x1)
- intersections.append(x)
- intersections.sort()
- for k in range(0, len(intersections), 2):
- if k + 1 >= len(intersections):
- break
- x_start = int(round(intersections[k]))
- x_end = int(round(intersections[k + 1]))
- x_start = max(0, min(width - 1, x_start))
- x_end = max(0, min(width - 1, x_end))
- grid[y, x_start:x_end + 1] = value
-
-def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
- """Filled ellipse with semi‑axes a,b (pixels), rotation angle (rad)."""
- cos_a = np.cos(angle)
- sin_a = np.sin(angle)
- rx = abs(a * cos_a) + abs(b * sin_a)
- ry = abs(a * sin_a) + abs(b * cos_a)
- x_min = int(max(0, cx - rx))
- x_max = int(min(width-1, cx + rx))
- y_min = int(max(0, cy - ry))
- y_max = int(min(height-1, cy + ry))
- for y in range(y_min, y_max + 1):
- for x in range(x_min, x_max + 1):
- dx = x - cx
- dy = y - cy
- u = dx * cos_a + dy * sin_a
- v = -dx * sin_a + dy * cos_a
- if (u/a)**2 + (v/b)**2 <= 1.0:
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
-def get_uv_from_face(obj, face_index, location):
- """World‑space location → UV coordinate on the mesh face."""
- me = obj.data
- local_loc = obj.matrix_world.inverted() @ location
- poly = me.polygons[face_index]
- vert_indices = poly.vertices
- loop_indices = poly.loop_indices
- if len(vert_indices) < 3:
- return None
- v0 = me.vertices[vert_indices[0]].co
- v1 = me.vertices[vert_indices[1]].co
- v2 = me.vertices[vert_indices[2]].co
- uv_layer = me.uv_layers.active.data
- uv0 = uv_layer[loop_indices[0]].uv
- uv1 = uv_layer[loop_indices[1]].uv
- uv2 = uv_layer[loop_indices[2]].uv
- def tri_area(a, b, c):
- return 0.5 * ((b - a).cross(c - a)).length
- total_area = tri_area(v0, v1, v2)
- if total_area == 0:
- return None
- w0 = tri_area(local_loc, v1, v2) / total_area
- w1 = tri_area(local_loc, v2, v0) / total_area
- w2 = 1.0 - w0 - w1
- uv = w0 * uv0 + w1 * uv1 + w2 * uv2
- return Vector((uv.x, uv.y))
-
-def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
- """Camera raycast through normalised sensor coordinates (0‑1)."""
- sx = (u_ndc - 0.5) * frame_width
- sy = (v_ndc - 0.5) * frame_height
- direction_cam = Vector((sx, sy, -1.0))
- direction_world = cam.matrix_world.to_quaternion() @ direction_cam
- direction_world.normalize()
- origin = cam.matrix_world.translation
- hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
- depsgraph, origin, direction_world)
- if hit and hit_obj == obj:
- return location, face_index
- return None, None
-
-def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)):
- """
- Fill all triangles of the active UV map with 'color' (default white).
- This marks every part of the mesh, visible or not, as white.
- """
- me = obj.data
- uv_layer = me.uv_layers.active
- if not uv_layer:
- print("Warning: No active UV layer – mesh rasterization skipped.")
- return
- uv_data = uv_layer.data
- for poly in me.polygons:
- # Get loop indices for this polygon
- loops = poly.loop_indices
- # Build UV polygon
- uv_poly = []
- for loop_idx in loops:
- uv = uv_data[loop_idx].uv
- uv_poly.append((uv.x, uv.y))
- # Convert UV (0‑1) to pixel coordinates
- pixel_poly = [(u * (tex_width - 1), v * (tex_height - 1)) for u, v in uv_poly]
- fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
-
-
-def rasterize_mesh_mask(obj, tex_width, tex_height):
- """Rasterize the active UV map to a boolean mesh mask."""
- mask = np.zeros((tex_height, tex_width), dtype=bool)
- me = obj.data
- uv_layer = me.uv_layers.active
- if not uv_layer:
- return mask
- uv_data = uv_layer.data
- for poly in me.polygons:
- uv_poly = []
- for loop_idx in poly.loop_indices:
- uv = uv_data[loop_idx].uv
- uv_poly.append((uv.x, uv.y))
- pixel_poly = [(u * (tex_width - 1), v * (tex_height - 1)) for u, v in uv_poly]
- fill_polygon_value(mask, tex_width, tex_height, pixel_poly, True)
- return mask
-
-
-def paint_mask_color(pixels, width, height, mask, color):
- """Paint all True pixels of a boolean mask with an RGBA color."""
- r, g, b, a = color
- ys, xs = np.nonzero(mask)
- for y, x in zip(ys, xs):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = (r, g, b, a)
-
-
-def complete_checker_gaps(pixels, width, height, mesh_mask, patch_state, max_gap_px=6):
- """
- Fill small white slivers inside the mesh by expanding nearby checker colors.
- Only grows locally (max_gap_px) so non-visible mesh areas remain white.
- patch_state: -1 = unassigned, 0 = black patch, 1 = white patch
- """
- for _ in range(max_gap_px):
- candidate = mesh_mask & (patch_state < 0)
- if not np.any(candidate):
- break
-
- black_counts = np.zeros_like(patch_state, dtype=np.uint8)
- white_counts = np.zeros_like(patch_state, dtype=np.uint8)
-
- for dy in (-1, 0, 1):
- for dx in (-1, 0, 1):
- if dx == 0 and dy == 0:
- continue
-
- src_y0 = max(0, -dy)
- src_y1 = height - max(0, dy)
- src_x0 = max(0, -dx)
- src_x1 = width - max(0, dx)
- dst_y0 = max(0, dy)
- dst_y1 = height - max(0, -dy)
- dst_x0 = max(0, dx)
- dst_x1 = width - max(0, -dx)
-
- neighbor_vals = patch_state[src_y0:src_y1, src_x0:src_x1]
- black_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 0)
- white_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 1)
-
- fillable = candidate & ((black_counts + white_counts) > 0)
- if not np.any(fillable):
- break
-
- patch_state[fillable] = np.where(
- black_counts[fillable] > white_counts[fillable], 0, 1
- ).astype(np.int8)
-
- paint_mask_color(pixels, width, height, patch_state == 0, (0.0, 0.0, 0.0, 1.0))
- paint_mask_color(pixels, width, height, patch_state == 1, (1.0, 1.0, 1.0, 1.0))
-
-def checker_patch_color(i, j):
- """Return a black/white checker color for patch index (i, j)."""
- if (i + j) % 2 == 0:
- return np.array([1.0, 1.0, 1.0], dtype=np.float32)
- return np.array([0.0, 0.0, 0.0], dtype=np.float32)
-
-# ----------------------------------------------------------------------
-# Mode 1: Colored Patches
-# ----------------------------------------------------------------------
-def generate_patch_texture(
- obj, cam, scene, depsgraph,
- nx=40, ny=30, r=0.05,
- texture_width=2048, texture_height=2048,
- output_path="//patch_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1),
- gap_fill_px=6
-):
- cam_data = cam.data
- render = scene.render
- res_x = render.resolution_x
- res_y = render.resolution_y
- if cam_data.sensor_fit == 'HORIZONTAL':
- sensor_width = cam_data.sensor_width
- sensor_height = sensor_width / (res_x / res_y)
- else:
- sensor_height = cam_data.sensor_height
- sensor_width = sensor_height * (res_x / res_y)
- focal = cam_data.lens
- frame_width = sensor_width / focal
- frame_height = sensor_height / focal
-
- patch_uv_polygons = []
- patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
-
- print(f"Raycasting {nx*ny} patches...")
- for j in range(ny):
- v_start = j / ny
- v_end = (j+1) / ny
- for i in range(nx):
- u_start = i / nx
- u_end = (i+1) / nx
- corners_ndc = [(u_start, v_start), (u_end, v_start),
- (u_end, v_end), (u_start, v_end)]
- uv_poly = []
- for (u_ndc, v_ndc) in corners_ndc:
- loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
- u_ndc, v_ndc, frame_width, frame_height)
- if loc is not None:
- uv = get_uv_from_face(obj, face_idx, loc)
- if uv is not None:
- uv_poly.append(uv)
- if len(uv_poly) < 3:
- patch_uv_polygons.append(None)
- continue
- patch_uv_polygons.append(uv_poly)
-
- patch_colors[j, i] = checker_patch_color(i, j)
-
- # Create image – start with black background
- img_name = "PatchTexture"
- if img_name in bpy.data.images:
- bpy.data.images.remove(bpy.data.images[img_name])
- image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
- pixels = list(image.pixels)
- mesh_mask = rasterize_mesh_mask(obj, texture_width, texture_height)
- patch_state = np.full((texture_height, texture_width), -1, dtype=np.int8)
-
- # Fill entire image with black (no mesh areas)
- fill_background(pixels, texture_width, texture_height, (0,0,0,1))
-
- # Rasterize all mesh UVs in white (visible + non‑visible parts)
- paint_mask_color(pixels, texture_width, texture_height, mesh_mask, (1,1,1,1))
-
- # Now draw the visible patches on top
- idx = 0
- for j in range(ny):
- for i in range(nx):
- uv_poly = patch_uv_polygons[idx]; idx += 1
- if uv_poly is None: continue
- pixel_poly = [(u*(texture_width-1), v*(texture_height-1)) for u,v in uv_poly]
- color = patch_colors[j,i]
- rgba = (color[0], color[1], color[2], 1.0)
- fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
- fill_polygon_value(
- patch_state,
- texture_width,
- texture_height,
- pixel_poly,
- 1 if np.mean(color) >= 0.5 else 0,
- )
-
- # Fill small steep-edge gaps from neighboring checker cells.
- complete_checker_gaps(
- pixels, texture_width, texture_height, mesh_mask, patch_state, max_gap_px=gap_fill_px
- )
-
- # (after drawing all patches)
- if uv_centre is not None:
- draw_cross(pixels, texture_width, texture_height,
- uv_centre[0], uv_centre[1],
- cross_length, cross_thickness, cross_color)
-
- image.pixels[:] = pixels
- image.update()
- abs_path = bpy.path.abspath(output_path)
- image.filepath_raw = abs_path
- image.file_format = 'PNG'
- image.save()
- print(f"Patch texture saved to {abs_path}")
- return image
-
-# ----------------------------------------------------------------------
-# Mode 2: Circular Dot Markers
-# ----------------------------------------------------------------------
-def generate_dot_texture(
- obj, cam, scene, depsgraph,
- dot_diameter_pixels=12,
- spacing_pixels=40,
- dot_color=(0,0,0,1), # RGBA 0-1
- texture_width=2048, texture_height=2048,
- output_path="//dot_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
-):
- render = scene.render
- res_x = render.resolution_x
- res_y = render.resolution_y
- cam_data = cam.data
- if cam_data.sensor_fit == 'HORIZONTAL':
- sensor_width = cam_data.sensor_width
- sensor_height = sensor_width / (res_x / res_y)
- else:
- sensor_height = cam_data.sensor_height
- sensor_width = sensor_height * (res_x / res_y)
- focal = cam_data.lens
- frame_width = sensor_width / focal
- frame_height = sensor_height / focal
-
- margin = spacing_pixels // 2
- x_centers = np.arange(margin, res_x, spacing_pixels)
- y_centers = np.arange(margin, res_y, spacing_pixels)
- offset_pix = max(2, dot_diameter_pixels // 4)
- du_ndc = offset_pix / res_x
- dv_ndc = offset_pix / res_y
-
- dots = [] # (cx_uv, cy_uv, a_uv, b_uv, angle_uv)
- print(f"Raycasting {len(x_centers)*len(y_centers)} dot centers...")
- for y_pix in y_centers:
- v_ndc = y_pix / res_y
- for x_pix in x_centers:
- u_ndc = x_pix / res_x
- center_loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
- u_ndc, v_ndc, frame_width, frame_height)
- if center_loc is None: continue
- center_uv = get_uv_from_face(obj, face_idx, center_loc)
- if center_uv is None: continue
-
- offsets = [
- ( u_ndc + du_ndc, v_ndc),
- ( u_ndc - du_ndc, v_ndc),
- ( u_ndc, v_ndc + dv_ndc),
- ( u_ndc, v_ndc - dv_ndc)
- ]
- uv_offsets = []
- valid = True
- for (u_off, v_off) in offsets:
- loc_off, f_idx_off = raycast_from_camera(scene, depsgraph, cam, obj,
- u_off, v_off, frame_width, frame_height)
- if loc_off is None:
- valid = False; break
- uv_off = get_uv_from_face(obj, f_idx_off, loc_off)
- if uv_off is None:
- valid = False; break
- uv_offsets.append(uv_off)
- if not valid: continue
-
- scale_u = 1.0 / res_x
- scale_v = 1.0 / res_y
- du_dx = (uv_offsets[0] - uv_offsets[1]) / (2 * offset_pix * scale_u)
- dv_dx = (uv_offsets[2] - uv_offsets[3]) / (2 * offset_pix * scale_v)
- r_pix = dot_diameter_pixels / 2.0
- a_uv = r_pix * du_dx.length * scale_u
- b_uv = r_pix * dv_dx.length * scale_v
- angle = np.arctan2(du_dx.y, du_dx.x) if du_dx.length > 0 else 0.0
- dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle))
-
- print(f"Found {len(dots)} valid dots.")
-
- if uv_centre is not None:
- draw_cross(pixels, texture_width, texture_height,
- uv_centre[0], uv_centre[1],
- cross_length, cross_thickness, cross_color)
-
- # Create image
- img_name = "DotTexture"
- if img_name in bpy.data.images:
- bpy.data.images.remove(bpy.data.images[img_name])
- image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
- pixels = list(image.pixels)
-
- # Black background (no mesh)
- fill_background(pixels, texture_width, texture_height, (0,0,0,1))
- # White for all mesh areas
- rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
- # Draw dots
- for (cx_uv, cy_uv, a_uv, b_uv, angle_uv) in dots:
- cx_px = cx_uv * (texture_width - 1)
- cy_px = cy_uv * (texture_height - 1)
- a_px = a_uv * texture_width
- b_px = b_uv * texture_height
- draw_ellipse(pixels, texture_width, texture_height,
- cx_px, cy_px, a_px, b_px, angle_uv, dot_color)
-
- image.pixels[:] = pixels
- image.update()
- abs_path = bpy.path.abspath(output_path)
- image.filepath_raw = abs_path
- image.file_format = 'PNG'
- image.save()
- print(f"Dot texture saved to {abs_path}")
- return image
-
-# ----------------------------------------------------------------------
-# Main
-# ----------------------------------------------------------------------
-def main():
- # ==================== CONFIGURATION ====================
- MODE = "patches"
- OBJ_NAME = "FingerMesh"
- CAM_NAME = "Camera"
- TEXTURE_WIDTH = 4096
- TEXTURE_HEIGHT = 4096
- OUTPUT_PATH = "//" + ("patch_texture.png" if MODE=="patches" else "dot_texture.png")
-
- # Patch mode
- PATCH_NX = 40; PATCH_NY = 40; COLOR_R = 0.05
- # Dot mode
- DOT_DIAMETER_PIX = 12; DOT_SPACING_PIX = 40; DOT_COLOR = (0,0,0,1)
- # Cross
- CROSS_LENGTH_PX = 40
- CROSS_THICKNESS_PX = 3
- CROSS_COLOR = (1, 0, 0, 1) # red cross
- # ======================================================
-
- start_time = time.time()
- scene = bpy.context.scene
- depsgraph = bpy.context.evaluated_depsgraph_get()
-
- if OBJ_NAME:
- obj = bpy.data.objects.get(OBJ_NAME)
- else:
- obj = bpy.context.active_object
- if obj is None or obj.type != 'MESH':
- raise ValueError("No valid mesh object found.")
-
- cam = bpy.data.objects.get(CAM_NAME) if CAM_NAME else scene.camera
- if cam is None or cam.type != 'CAMERA':
- raise ValueError("No camera found.")
-
- if not obj.data.uv_layers.active:
- obj.data.uv_layers.new(name="UVMap")
-
- # Compute UV centre
- uv_layer = obj.data.uv_layers.active
- uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
- uv_min = uv_coords.min(axis=0)
- uv_max = uv_coords.max(axis=0)
- uv_centre = (uv_min + uv_max) / 2.0
- print(f"UV centre: ({uv_centre[0]:.4f}, {uv_centre[1]:.4f})")
-
-
- # Compute UV bounding box centre
- me = obj.data
- uv_layer = me.uv_layers.active
- uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
- uv_min = uv_coords.min(axis=0)
- uv_max = uv_coords.max(axis=0)
- uv_centre = (uv_min + uv_max) / 2.0
- print(f"UV centre: {uv_centre[0]:.4f}, {uv_centre[1]:.4f}")
-
- if MODE == "patches":
- generate_patch_texture(
- obj, cam, scene, depsgraph,
- nx=PATCH_NX, ny=PATCH_NY, r=COLOR_R,
- texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
- output_path=OUTPUT_PATH,
- uv_centre=uv_centre,
- cross_length=CROSS_LENGTH_PX,
- cross_thickness=CROSS_THICKNESS_PX,
- cross_color=CROSS_COLOR
- )
- elif MODE == "dots":
- generate_dot_texture(
- obj, cam, scene, depsgraph,
- dot_diameter_pixels=DOT_DIAMETER_PIX,
- spacing_pixels=DOT_SPACING_PIX,
- dot_color=DOT_COLOR,
- texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
- output_path=OUTPUT_PATH,
- uv_centre=uv_centre,
- cross_length=CROSS_LENGTH_PX,
- cross_thickness=CROSS_THICKNESS_PX,
- cross_color=CROSS_COLOR
- )
-
- print(f"Completed in {time.time()-start_time:.2f} seconds.")
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/tools/3d_patch_generation_blender/dd02c_0607.blend b/tools/3d_patch_generation_blender/dd02c_0607.blend
deleted file mode 100644
index 9dbb163..0000000
Binary files a/tools/3d_patch_generation_blender/dd02c_0607.blend and /dev/null differ
diff --git a/tools/3d_patch_generation_blender/dd02c_0607.blend1 b/tools/3d_patch_generation_blender/dd02c_0607.blend1
deleted file mode 100644
index eac715a..0000000
Binary files a/tools/3d_patch_generation_blender/dd02c_0607.blend1 and /dev/null differ
diff --git a/tools/3d_patch_generation_blender/mapto3d_video.py b/tools/3d_patch_generation_blender/mapto3d_video.py
deleted file mode 100644
index e14d108..0000000
--- a/tools/3d_patch_generation_blender/mapto3d_video.py
+++ /dev/null
@@ -1,411 +0,0 @@
-import argparse
-import os
-import time
-
-import cv2
-import numpy as np
-
-
-def load_exported_data(data_dir):
- """Load Blender-exported mesh/camera files."""
- mesh = np.load(os.path.join(data_dir, "mesh.npz"))
- cam_data = np.load(os.path.join(data_dir, "camera_intrinsics.npz"))
-
- xyz_map_path = os.path.join(data_dir, "pixel_to_xyz.npz")
- if not os.path.exists(xyz_map_path):
- raise FileNotFoundError(
- f"Real-time video viewing requires precomputed pixel_to_xyz.npz: {xyz_map_path}"
- )
-
- xyz_map = np.load(xyz_map_path)["xyz_map"]
- width = int(cam_data["width"])
- height = int(cam_data["height"])
- if xyz_map.shape[:2] != (height, width):
- raise ValueError(
- f"pixel_to_xyz shape {xyz_map.shape[:2]} does not match camera size {(height, width)}"
- )
-
- return {
- "vertices": mesh["vertices"],
- "faces": mesh["faces"],
- "uv_data": mesh["uv_data"],
- "loop_to_vertex": mesh["loop_to_vertex"],
- "K": cam_data["K"],
- "width": width,
- "height": height,
- "world_to_cam": np.load(os.path.join(data_dir, "world_to_cam.npy")),
- "xyz_map": xyz_map,
- }
-
-
-def prepare_static_points(data, display_stride=4):
- """
- Prepare fixed 3D points from the precomputed pixel-to-3D map.
-
- The geometry does not change between video frames, so real-time viewing only
- needs to update point colors.
- """
- xyz_map = data["xyz_map"]
- valid_mask = ~np.isnan(xyz_map[:, :, 0])
- valid_points = xyz_map[valid_mask].reshape(-1, 3)
- if len(valid_points) == 0:
- raise ValueError("pixel_to_xyz.npz contains no valid mesh hit points.")
-
- display_stride = max(1, int(display_stride))
- display_indices = np.arange(len(valid_points))[::display_stride]
- return valid_mask, display_indices, valid_points[display_indices]
-
-
-def frame_to_rgb_colors(frame_bgr, data, valid_mask, display_indices, flip_vertical=True):
- """Convert a video frame to RGB point colors aligned with the exported map."""
- target_w = int(data["width"])
- target_h = int(data["height"])
- h, w = frame_bgr.shape[:2]
- if (w, h) != (target_w, target_h):
- frame_bgr = cv2.resize(frame_bgr, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
-
- frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
- if flip_vertical:
- frame_rgb = np.flipud(frame_rgb).copy()
-
- colors = frame_rgb.astype(np.float64) / 255.0
- valid_colors = colors[valid_mask].reshape(-1, 3)
- return valid_colors[display_indices]
-
-
-def init_open3d_viewer(points, colors):
- import open3d as o3d
-
- vis = o3d.visualization.Visualizer()
- vis.create_window(window_name="Real-time 3D Point Cloud", width=1280, height=720)
-
- pcd = o3d.geometry.PointCloud()
- pcd.points = o3d.utility.Vector3dVector(points.astype(np.float64))
- pcd.colors = o3d.utility.Vector3dVector(colors.astype(np.float64))
- vis.add_geometry(pcd)
-
- render_option = vis.get_render_option()
- render_option.point_size = 2.0
- render_option.background_color = np.array([0.0, 0.0, 0.0])
-
- vis.poll_events()
- vis.update_renderer()
- return vis, pcd, o3d
-
-
-def save_colored_pointcloud(points, colors, output_path):
- """Save displayed points as a PLY file."""
- valid_colors = (colors * 255).clip(0, 255).astype(np.uint8)
- with open(output_path, "w", encoding="utf-8") as f:
- f.write("ply\n")
- f.write("format ascii 1.0\n")
- f.write(f"element vertex {len(points)}\n")
- f.write("property float x\n")
- f.write("property float y\n")
- f.write("property float z\n")
- f.write("property uchar red\n")
- f.write("property uchar green\n")
- f.write("property uchar blue\n")
- f.write("end_header\n")
- for pt, col in zip(points, valid_colors):
- f.write(f"{pt[0]} {pt[1]} {pt[2]} {col[0]} {col[1]} {col[2]}\n")
- print(f"Saved point cloud to {output_path}")
-
-
-def view_video_pointcloud(
- video_path,
- data,
- display_stride=4,
- frame_step=1,
- flip_vertical=True,
- loop=False,
- save_snapshot_path=None,
-):
- """Open a video and show it as a real-time colored 3D point cloud."""
- cap = cv2.VideoCapture(video_path)
- if not cap.isOpened():
- raise FileNotFoundError(f"Could not open video: {video_path}")
-
- valid_mask, display_indices, display_points = prepare_static_points(
- data,
- display_stride=display_stride,
- )
-
- ok, frame = cap.read()
- if not ok:
- cap.release()
- raise RuntimeError(f"Could not read first frame from video: {video_path}")
-
- display_colors = frame_to_rgb_colors(
- frame,
- data,
- valid_mask,
- display_indices,
- flip_vertical=flip_vertical,
- )
- vis, pcd, o3d = init_open3d_viewer(display_points, display_colors)
-
- print("Real-time point cloud viewer started.")
- print("Close the Open3D window or press Ctrl+C in the terminal to stop.")
- print(f"Displayed points: {len(display_points)} (stride={display_stride})")
-
- frame_idx = 0
- shown_frames = 0
- t0 = time.perf_counter()
-
- try:
- while True:
- alive = vis.poll_events()
- if not alive:
- break
-
- ok, frame = cap.read()
- if not ok:
- if loop:
- cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
- continue
- break
-
- frame_idx += 1
- if frame_idx % max(1, frame_step) != 0:
- continue
-
- display_colors = frame_to_rgb_colors(
- frame,
- data,
- valid_mask,
- display_indices,
- flip_vertical=flip_vertical,
- )
- pcd.colors = o3d.utility.Vector3dVector(display_colors)
- vis.update_geometry(pcd)
- vis.update_renderer()
-
- shown_frames += 1
- if shown_frames % 30 == 0:
- elapsed = max(time.perf_counter() - t0, 1e-6)
- print(f"Displayed {shown_frames} frames, viewer FPS={shown_frames / elapsed:.1f}")
-
- finally:
- if save_snapshot_path:
- save_colored_pointcloud(display_points, display_colors, save_snapshot_path)
- cap.release()
- vis.destroy_window()
-
-
-def parse_args():
- parser = argparse.ArgumentParser(description="View a video as a real-time 3D point cloud.")
- parser.add_argument("--data-dir", default="./exported_data", help="Blender exported_data folder")
- parser.add_argument("--video", default="20260602-094916.mp4", help="Input video path")
- parser.add_argument("--display-stride", type=int, default=4,
- help="Show every Nth valid point. Increase for faster viewing.")
- parser.add_argument("--frame-step", type=int, default=1,
- help="Process every Nth video frame.")
- parser.add_argument("--no-flip-vertical", action="store_true",
- help="Disable vertical flip before color mapping.")
- parser.add_argument("--loop", action="store_true", help="Loop video playback.")
- parser.add_argument("--save-snapshot", default="",
- help="Optional PLY path for saving the last displayed frame.")
- return parser.parse_args()
-
-
-if __name__ == "__main__":
- args = parse_args()
- exported = load_exported_data(args.data_dir)
- view_video_pointcloud(
- args.video,
- exported,
- display_stride=args.display_stride,
- frame_step=args.frame_step,
- flip_vertical=not args.no_flip_vertical,
- loop=args.loop,
- save_snapshot_path=args.save_snapshot or None,
- )
-import numpy as np
-import cv2
-import os
-
-def load_exported_data(data_dir):
- """Load all exported files."""
- # Mesh
- mesh = np.load(os.path.join(data_dir, "mesh.npz"))
- vertices = mesh['vertices']
- faces = mesh['faces']
- uv_data = mesh['uv_data']
- loop_to_vertex = mesh['loop_to_vertex']
-
- # Camera intrinsics
- cam_data = np.load(os.path.join(data_dir, "camera_intrinsics.npz"))
- K = cam_data['K']
- width = int(cam_data['width'])
- height = int(cam_data['height'])
-
- # Camera pose (world-to-camera)
- world_to_cam = np.load(os.path.join(data_dir, "world_to_cam.npy"))
-
- # Precomputed pixel-to-3D map (optional)
- xyz_map_path = os.path.join(data_dir, "pixel_to_xyz.npz")
- if os.path.exists(xyz_map_path):
- xyz_map = np.load(xyz_map_path)['xyz_map']
- else:
- xyz_map = None
-
- return {
- 'vertices': vertices,
- 'faces': faces,
- 'uv_data': uv_data,
- 'loop_to_vertex': loop_to_vertex,
- 'K': K,
- 'width': width,
- 'height': height,
- 'world_to_cam': world_to_cam,
- 'xyz_map': xyz_map
- }
-
-def ray_intersect_triangle(ray_origin, ray_dir, v0, v1, v2):
- """Möller–Trumbore intersection algorithm. Returns t (distance) or None."""
- epsilon = 1e-8
- edge1 = v1 - v0
- edge2 = v2 - v0
- h = np.cross(ray_dir, edge2)
- a = np.dot(edge1, h)
- if abs(a) < epsilon:
- return None
- f = 1.0 / a
- s = ray_origin - v0
- u = f * np.dot(s, h)
- if u < 0.0 or u > 1.0:
- return None
- q = np.cross(s, edge1)
- v = f * np.dot(ray_dir, q)
- if v < 0.0 or u + v > 1.0:
- return None
- t = f * np.dot(edge2, q)
- if t > epsilon:
- return t
- return None
-
-def pixel_to_3d_ray(u, v, K, world_to_cam):
- """
- Given pixel coordinates (u,v) and camera intrinsics K,
- return a ray in world coordinates: origin and direction.
- """
- # Invert K to get normalized image coordinates
- fx = K[0,0]; fy = K[1,1]; cx = K[0,2]; cy = K[1,2]
- x_norm = (u - cx) / fx
- y_norm = (v - cy) / fy
-
- # Ray in camera space: (x_norm, y_norm, 1.0)
- ray_cam = np.array([x_norm, y_norm, 1.0], dtype=np.float64)
-
- # Transform to world
- cam_to_world = np.linalg.inv(world_to_cam)
- R = cam_to_world[:3, :3]
- t = cam_to_world[:3, 3]
- ray_world_dir = R @ ray_cam
- ray_world_dir /= np.linalg.norm(ray_world_dir)
- ray_world_origin = t
-
- return ray_world_origin, ray_world_dir
-
-def map_image_to_3d(image_path, data, use_precomputed=True, flip_vertical=True):
- """
- Map each pixel of the image to a 3D world point.
- If use_precomputed and xyz_map exists, return that directly.
- If `flip_vertical` is True, flip the loaded image top-to-bottom so its
- row order matches the exported Blender camera/pixel mapping.
- Otherwise, perform raycasting on the mesh.
- Returns:
- points: (H, W, 3) array of 3D points (NaN for misses).
- colors: (H, W, 3) array of RGB colors from image.
- """
- img = cv2.imread(image_path)
- if img is None:
- raise FileNotFoundError(f"Could not read image: {image_path}")
- img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # to RGB
- if flip_vertical:
- img = np.flipud(img).copy()
- h, w = img.shape[:2]
-
- if use_precomputed and data['xyz_map'] is not None:
- # Use precomputed map (must match resolution)
- if h != data['height'] or w != data['width']:
- raise ValueError("Image resolution does not match precomputed map.")
- points = data['xyz_map'].copy()
- colors = img.astype(np.float32) / 255.0
- return points, colors
-
- # Otherwise, perform raycasting on the mesh
- vertices = data['vertices']
- faces = data['faces']
- K = data['K']
- world_to_cam = data['world_to_cam']
-
- points = np.full((h, w, 3), np.nan, dtype=np.float32)
- colors = img.astype(np.float32) / 255.0
-
- # For speed, we can use a bounding volume hierarchy (BVH).
- # Here we implement a simple brute-force over all faces (slow for large meshes).
- # In practice, precomputing the xyz_map is strongly recommended.
-
- for y in range(h):
- for x in range(w):
- origin, direction = pixel_to_3d_ray(x, y, K, world_to_cam)
- best_t = np.inf
- best_hit = None
- for face in faces:
- v0 = vertices[face[0]]
- v1 = vertices[face[1]]
- v2 = vertices[face[2]]
- t = ray_intersect_triangle(origin, direction, v0, v1, v2)
- if t is not None and t < best_t:
- best_t = t
- best_hit = origin + direction * t
- if best_hit is not None:
- points[y, x] = best_hit
-
- return points, colors
-
-def save_colored_pointcloud(points, colors, output_path):
- """Save as PLY file (colored point cloud)."""
- # Flatten valid points
- mask = ~np.isnan(points[:,:,0])
- valid_points = points[mask].reshape(-1, 3)
- valid_colors = (colors[mask].reshape(-1, 3) * 255).astype(np.uint8)
-
- with open(output_path, 'w') as f:
- f.write("ply\n")
- f.write("format ascii 1.0\n")
- f.write(f"element vertex {len(valid_points)}\n")
- f.write("property float x\n")
- f.write("property float y\n")
- f.write("property float z\n")
- f.write("property uchar red\n")
- f.write("property uchar green\n")
- f.write("property uchar blue\n")
- f.write("end_header\n")
- for pt, col in zip(valid_points, valid_colors):
- f.write(f"{pt[0]} {pt[1]} {pt[2]} {col[0]} {col[1]} {col[2]}\n")
- print(f"Saved point cloud to {output_path}")
-
-# if __name__ == "__main__":
-# data_dir = "./exported_data" # Path to exported files
-# image_path = "captured_image_1.jpg" # Input camera image
-# output_ply = "mapped_points.ply"
-
-# data = load_exported_data(data_dir)
-# points, colors = map_image_to_3d(
-# image_path,
-# data,
-# use_precomputed=True,
-# flip_vertical=True,
-# )
-# save_colored_pointcloud(points, colors, output_ply)
-
-# # Optional: Visualize using Open3D
-# import open3d as o3d
-# pcd = o3d.geometry.PointCloud()
-# mask = ~np.isnan(points[:,:,0])
-# pcd.points = o3d.utility.Vector3dVector(points[mask].reshape(-1,3))
-# pcd.colors = o3d.utility.Vector3dVector(colors[mask].reshape(-1,3))
-# o3d.visualization.draw_geometries([pcd])
\ No newline at end of file
diff --git a/tools/3d_patch_generation_blender/patch_texture_0607_X80Y45.png b/tools/3d_patch_generation_blender/patch_texture_0607_X80Y45.png
deleted file mode 100644
index 830ec01..0000000
Binary files a/tools/3d_patch_generation_blender/patch_texture_0607_X80Y45.png and /dev/null differ
diff --git a/tools/3d_patch_generation_blender/patch_texture_0621_X80Y45.png b/tools/3d_patch_generation_blender/patch_texture_0621_X80Y45.png
deleted file mode 100644
index e1d1229..0000000
Binary files a/tools/3d_patch_generation_blender/patch_texture_0621_X80Y45.png and /dev/null differ
diff --git a/tools/3d_patch_generation_blender/patch_texture_0703_X80Y45.png b/tools/3d_patch_generation_blender/patch_texture_0703_X80Y45.png
deleted file mode 100644
index 70ab865..0000000
Binary files a/tools/3d_patch_generation_blender/patch_texture_0703_X80Y45.png and /dev/null differ
diff --git a/tools/3d_patch_generation_blender/random_patch_dd02c.py b/tools/3d_patch_generation_blender/random_patch_dd02c.py
deleted file mode 100644
index c3a282b..0000000
--- a/tools/3d_patch_generation_blender/random_patch_dd02c.py
+++ /dev/null
@@ -1,917 +0,0 @@
-"""
-Texture Generator for Arbitrary 3D Surfaces (Blender Script)
-- Mode: "patches" (colored patches), "checker" (black/white patches),
- or "dots" (circular markers)
-- Mesh UVs are filled white so that non‑visible parts become white.
-- Texture background (no‑mesh areas) stays black.
-"""
-
-import bpy
-import numpy as np
-import os
-import time
-from mathutils import Vector
-
-# ----------------------------------------------------------------------
-# Common Utilities
-# ----------------------------------------------------------------------
-def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
- """
- Draw a cross (plus sign) in pixel space.
- cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left.
- length_px: total length of each arm (pixels).
- thickness_px: line thickness (odd number gives symmetric result).
- """
- # Convert UV centre to pixel coordinates (y from bottom)
- cx = int(round(cx_uv * (width - 1)))
- cy = int(round(cy_uv * (height - 1)))
-
- half_len = length_px // 2
- half_thick = thickness_px // 2
-
- # Horizontal arm
- x0 = max(0, cx - half_len)
- x1 = min(width - 1, cx + half_len)
- y0 = max(0, cy - half_thick)
- y1 = min(height - 1, cy + half_thick)
- for y in range(y0, y1 + 1):
- for x in range(x0, x1 + 1):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
- # Vertical arm
- x0 = max(0, cx - half_thick)
- x1 = min(width - 1, cx + half_thick)
- y0 = max(0, cy - half_len)
- y1 = min(height - 1, cy + half_len)
- for y in range(y0, y1 + 1):
- for x in range(x0, x1 + 1):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
-def fill_background(pixels, width, height, color):
- """Fill the entire image with a solid RGBA color (0-1 floats)."""
- r, g, b, a = color
- for y in range(height):
- for x in range(width):
- idx = (y * width + x) * 4
- pixels[idx] = r
- pixels[idx+1] = g
- pixels[idx+2] = b
- pixels[idx+3] = a
-
-def fill_polygon(image, pixels, width, height, polygon, color):
- """Fill a polygon in the pixel array (flattened, RGBA)."""
- poly = [(int(round(x)), int(round(y))) for x, y in polygon]
- if len(poly) < 3:
- return
- min_x = max(0, min(p[0] for p in poly))
- max_x = min(width-1, max(p[0] for p in poly))
- min_y = max(0, min(p[1] for p in poly))
- max_y = min(height-1, max(p[1] for p in poly))
- for y in range(min_y, max_y + 1):
- intersections = []
- for i in range(len(poly)):
- x1, y1 = poly[i]
- x2, y2 = poly[(i+1) % len(poly)]
- if y1 == y2:
- continue
- if (y1 <= y < y2) or (y2 <= y < y1):
- t = (y - y1) / (y2 - y1)
- x = x1 + t * (x2 - x1)
- intersections.append(x)
- intersections.sort()
- for k in range(0, len(intersections), 2):
- if k+1 >= len(intersections):
- break
- x_start = int(round(intersections[k]))
- x_end = int(round(intersections[k+1]))
- x_start = max(0, min(width-1, x_start))
- x_end = max(0, min(width-1, x_end))
- for x in range(x_start, x_end + 1):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
-def fill_polygon_value(grid, width, height, polygon, value):
- """Fill a polygon into a 2D scalar grid."""
- poly = [(int(round(x)), int(round(y))) for x, y in polygon]
- if len(poly) < 3:
- return
- min_x = max(0, min(p[0] for p in poly))
- max_x = min(width - 1, max(p[0] for p in poly))
- min_y = max(0, min(p[1] for p in poly))
- max_y = min(height - 1, max(p[1] for p in poly))
- for y in range(min_y, max_y + 1):
- intersections = []
- for i in range(len(poly)):
- x1, y1 = poly[i]
- x2, y2 = poly[(i + 1) % len(poly)]
- if y1 == y2:
- continue
- if (y1 <= y < y2) or (y2 <= y < y1):
- t = (y - y1) / (y2 - y1)
- x = x1 + t * (x2 - x1)
- intersections.append(x)
- intersections.sort()
- for k in range(0, len(intersections), 2):
- if k + 1 >= len(intersections):
- break
- x_start = int(round(intersections[k]))
- x_end = int(round(intersections[k + 1]))
- x_start = max(0, min(width - 1, x_start))
- x_end = max(0, min(width - 1, x_end))
- grid[y, x_start:x_end + 1] = value
-
-def unwrap_periodic_values(values):
- """
- Unwrap periodic UV coordinates so points straddling the 0/1 seam stay local.
- """
- arr = np.asarray(values, dtype=np.float64).copy()
- if arr.size == 0:
- return arr
- if arr.max() - arr.min() > 0.5:
- arr[arr < 0.5] += 1.0
- return arr
-
-def wrap_uv_polygon_for_raster(uv_poly):
- """
- Return a seam-aware version of a UV polygon.
-
- UV islands may cross the 0/1 seam. If we rasterize those raw coordinates as a
- single polygon, Blender's repeated UVs turn into a long diagonal triangle
- across the texture. We unwrap seam-crossing polygons first, then draw wrapped
- copies in the texture tile(s) they overlap.
- """
- if len(uv_poly) < 3:
- return []
-
- uv_arr = np.array([(float(u), float(v)) for u, v in uv_poly], dtype=np.float64)
- uv_arr[:, 0] = unwrap_periodic_values(uv_arr[:, 0])
- uv_arr[:, 1] = unwrap_periodic_values(uv_arr[:, 1])
- return [tuple(pt) for pt in uv_arr]
-
-def uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
- """
- Convert one UV polygon into one or more pixel polygons, duplicating seam-
- crossing polygons into adjacent tiles so rasterization stays local.
- """
- unwrapped = wrap_uv_polygon_for_raster(uv_poly)
- if len(unwrapped) < 3:
- return []
-
- u_vals = [u for u, _ in unwrapped]
- v_vals = [v for _, v in unwrapped]
- u_shift_start = int(np.floor(min(u_vals)))
- u_shift_end = int(np.floor(max(u_vals)))
- v_shift_start = int(np.floor(min(v_vals)))
- v_shift_end = int(np.floor(max(v_vals)))
-
- pixel_polys = []
- for u_shift in range(u_shift_start, u_shift_end + 1):
- for v_shift in range(v_shift_start, v_shift_end + 1):
- shifted = [(u - u_shift, v - v_shift) for u, v in unwrapped]
- shifted_u = [u for u, _ in shifted]
- shifted_v = [v for _, v in shifted]
- if max(shifted_u) < 0.0 or min(shifted_u) > 1.0:
- continue
- if max(shifted_v) < 0.0 or min(shifted_v) > 1.0:
- continue
- pixel_polys.append([
- (u * (tex_width - 1), v * (tex_height - 1))
- for u, v in shifted
- ])
- return pixel_polys
-
-def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
- """Filled ellipse with semi‑axes a,b (pixels), rotation angle (rad)."""
- cos_a = np.cos(angle)
- sin_a = np.sin(angle)
- rx = abs(a * cos_a) + abs(b * sin_a)
- ry = abs(a * sin_a) + abs(b * cos_a)
- x_min = int(max(0, cx - rx))
- x_max = int(min(width-1, cx + rx))
- y_min = int(max(0, cy - ry))
- y_max = int(min(height-1, cy + ry))
- for y in range(y_min, y_max + 1):
- for x in range(x_min, x_max + 1):
- dx = x - cx
- dy = y - cy
- u = dx * cos_a + dy * sin_a
- v = -dx * sin_a + dy * cos_a
- if (u/a)**2 + (v/b)**2 <= 1.0:
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
-def get_uv_from_face(obj, face_index, location):
- """World‑space location → UV coordinate on the mesh face."""
- me = obj.data
- local_loc = obj.matrix_world.inverted() @ location
- poly = me.polygons[face_index]
- vert_indices = poly.vertices
- loop_indices = poly.loop_indices
- if len(vert_indices) < 3:
- return None
- v0 = me.vertices[vert_indices[0]].co
- v1 = me.vertices[vert_indices[1]].co
- v2 = me.vertices[vert_indices[2]].co
- uv_layer = me.uv_layers.active.data
- uv0 = uv_layer[loop_indices[0]].uv
- uv1 = uv_layer[loop_indices[1]].uv
- uv2 = uv_layer[loop_indices[2]].uv
- def tri_area(a, b, c):
- return 0.5 * ((b - a).cross(c - a)).length
- total_area = tri_area(v0, v1, v2)
- if total_area == 0:
- return None
- w0 = tri_area(local_loc, v1, v2) / total_area
- w1 = tri_area(local_loc, v2, v0) / total_area
- w2 = 1.0 - w0 - w1
- uv = w0 * uv0 + w1 * uv1 + w2 * uv2
- return Vector((uv.x, uv.y))
-
-def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
- """Camera raycast through normalised sensor coordinates (0‑1)."""
- sx = (u_ndc - 0.5) * frame_width
- sy = (v_ndc - 0.5) * frame_height
- direction_cam = Vector((sx, sy, -1.0))
- direction_world = cam.matrix_world.to_quaternion() @ direction_cam
- direction_world.normalize()
- origin = cam.matrix_world.translation
- hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
- depsgraph, origin, direction_world)
- if hit and hit_obj == obj:
- return location, face_index
- return None, None
-
-def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
- """Camera raycast through normalized sensor coordinates and return UV."""
- loc, face_idx = raycast_from_camera(
- scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
- )
- if loc is None:
- return None
- return get_uv_from_face(obj, face_idx, loc)
-
-def convex_hull_uv(points):
- """Return UV points ordered around their 2D convex hull."""
- unwrapped = wrap_uv_polygon_for_raster([(p.x, p.y) for p in points])
- unique = sorted(set(unwrapped))
- if len(unique) < 3:
- return None
-
- def cross(o, a, b):
- return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
-
- lower = []
- for p in unique:
- while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
- lower.pop()
- lower.append(p)
-
- upper = []
- for p in reversed(unique):
- while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
- upper.pop()
- upper.append(p)
-
- hull = lower[:-1] + upper[:-1]
- if len(hull) < 3:
- return None
- return [Vector((u, v)) for u, v in hull]
-
-def sample_patch_uv_polygon(
- scene, depsgraph, cam, obj,
- u_start, u_end, v_start, v_end,
- frame_width, frame_height,
- edge_sample_grid=5
-):
- """
- Build a UV polygon for one camera-space patch.
-
- Full interior cells use the four corners. Edge cells that only partially hit
- the mesh are sampled more densely and converted to a convex hull so they are
- not dropped just because some corners miss the silhouette.
- """
- corners_ndc = [
- (u_start, v_start),
- (u_end, v_start),
- (u_end, v_end),
- (u_start, v_end),
- ]
-
- corner_uvs = []
- for u_ndc, v_ndc in corners_ndc:
- uv = raycast_uv_from_camera(
- scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
- )
- if uv is not None:
- corner_uvs.append(uv)
-
- if len(corner_uvs) >= 3:
- return corner_uvs
-
- sampled_uvs = list(corner_uvs)
- for sy in range(edge_sample_grid):
- v_ndc = v_start + (v_end - v_start) * sy / (edge_sample_grid - 1)
- for sx in range(edge_sample_grid):
- u_ndc = u_start + (u_end - u_start) * sx / (edge_sample_grid - 1)
- uv = raycast_uv_from_camera(
- scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
- )
- if uv is not None:
- sampled_uvs.append(uv)
-
- return convex_hull_uv(sampled_uvs)
-
-def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)):
- """
- Fill all triangles of the active UV map with 'color' (default white).
- This marks every part of the mesh, visible or not, as white.
- """
- me = obj.data
- uv_layer = me.uv_layers.active
- if not uv_layer:
- print("Warning: No active UV layer – mesh rasterization skipped.")
- return
- uv_data = uv_layer.data
- for poly in me.polygons:
- # Get loop indices for this polygon
- loops = poly.loop_indices
- # Build UV polygon
- uv_poly = []
- for loop_idx in loops:
- uv = uv_data[loop_idx].uv
- uv_poly.append((uv.x, uv.y))
- # Convert UV (0‑1) to pixel coordinates
- for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
- fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
-
-def rasterize_mesh_mask(obj, tex_width, tex_height):
- """Rasterize the active UV map to a boolean mesh mask."""
- mask = np.zeros((tex_height, tex_width), dtype=bool)
- me = obj.data
- uv_layer = me.uv_layers.active
- if not uv_layer:
- return mask
- uv_data = uv_layer.data
- for poly in me.polygons:
- uv_poly = []
- for loop_idx in poly.loop_indices:
- uv = uv_data[loop_idx].uv
- uv_poly.append((uv.x, uv.y))
- for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
- fill_polygon_value(mask, tex_width, tex_height, pixel_poly, True)
- return mask
-
-def paint_mask_color(pixels, width, height, mask, color):
- """Paint all True pixels of a boolean mask with an RGBA color."""
- r, g, b, a = color
- ys, xs = np.nonzero(mask)
- for y, x in zip(ys, xs):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = (r, g, b, a)
-
-def complete_checker_gaps(pixels, width, height, mesh_mask, patch_state, max_gap_px=6):
- """
- Fill small white slivers inside the mesh by expanding nearby checker colors.
-
- `patch_state`: -1 = unassigned, 0 = black patch, 1 = white patch.
- """
- for _ in range(max_gap_px):
- candidate = mesh_mask & (patch_state < 0)
- if not np.any(candidate):
- break
-
- black_counts = np.zeros_like(patch_state, dtype=np.uint8)
- white_counts = np.zeros_like(patch_state, dtype=np.uint8)
-
- for dy in (-1, 0, 1):
- for dx in (-1, 0, 1):
- if dx == 0 and dy == 0:
- continue
-
- src_y0 = max(0, -dy)
- src_y1 = height - max(0, dy)
- src_x0 = max(0, -dx)
- src_x1 = width - max(0, dx)
- dst_y0 = max(0, dy)
- dst_y1 = height - max(0, -dy)
- dst_x0 = max(0, dx)
- dst_x1 = width - max(0, -dx)
-
- neighbor_vals = patch_state[src_y0:src_y1, src_x0:src_x1]
- black_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 0)
- white_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 1)
-
- fillable = candidate & ((black_counts + white_counts) > 0)
- if not np.any(fillable):
- break
-
- patch_state[fillable] = np.where(
- black_counts[fillable] > white_counts[fillable], 0, 1
- ).astype(np.int8)
-
- paint_mask_color(pixels, width, height, patch_state == 0, (0.0, 0.0, 0.0, 1.0))
- paint_mask_color(pixels, width, height, patch_state == 1, (1.0, 1.0, 1.0, 1.0))
-
-# ----------------------------------------------------------------------
-# Color generation (for patch mode)
-# ----------------------------------------------------------------------
-def merge_intervals(intervals):
- if not intervals: return []
- intervals.sort(key=lambda x: x[0])
- merged = [intervals[0]]
- for current in intervals[1:]:
- last = merged[-1]
- if current[0] <= last[1]:
- merged[-1] = (last[0], max(last[1], current[1]))
- else:
- merged.append(current)
- return merged
-
-def get_allowed_intervals(neighbor_values, delta):
- forbidden = []
- for c in neighbor_values:
- low = max(0, c - delta)
- high = min(1, c + delta)
- forbidden.append((low, high))
- merged = merge_intervals(forbidden)
- allowed = []
- if merged:
- if merged[0][0] > 0:
- allowed.append((0, merged[0][0]))
- for i in range(1, len(merged)):
- allowed.append((merged[i-1][1], merged[i][0]))
- if merged[-1][1] < 1:
- allowed.append((merged[-1][1], 1))
- else:
- allowed.append((0, 1))
- return allowed
-
-def sample_from_allowed(allowed):
- lengths = [end - start for start, end in allowed]
- total_length = sum(lengths)
- if total_length <= 0: return None
- point = np.random.rand() * total_length
- cumulative = 0
- for (start, end), length in zip(allowed, lengths):
- cumulative += length
- if point <= cumulative:
- return np.random.uniform(start, end)
- return allowed[-1][0]
-
-def sample_channel(neighbor_values, delta):
- allowed = get_allowed_intervals(neighbor_values, delta)
- if not allowed: return None
- return sample_from_allowed(allowed)
-
-def generate_valid_color(neighbors, r):
- delta = np.sqrt(r)
- color = np.zeros(3)
- for channel in range(3):
- neighbor_channel = [n[channel] for n in neighbors]
- sampled = sample_channel(neighbor_channel, delta)
- if sampled is None:
- sampled = np.random.rand()
- color[channel] = sampled
- return color
-
-def checker_patch_color(i, j):
- """Return a black/white checker color for patch index (i, j)."""
- if (i + j) % 2 == 0:
- return np.array([1.0, 1.0, 1.0], dtype=np.float32)
- return np.array([0.0, 0.0, 0.0], dtype=np.float32)
-
-# ----------------------------------------------------------------------
-# Mode 1: Colored Patches
-# ----------------------------------------------------------------------
-def generate_patch_texture(
- obj, cam, scene, depsgraph,
- nx=40, ny=30, r=0.05,
- texture_width=2048, texture_height=2048,
- output_path="//patch_texture.png", uv_centre=None, cross_length=40,
- cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5
-):
- cam_data = cam.data
- render = scene.render
- res_x = render.resolution_x
- res_y = render.resolution_y
- if cam_data.sensor_fit == 'HORIZONTAL':
- sensor_width = cam_data.sensor_width
- sensor_height = sensor_width / (res_x / res_y)
- else:
- sensor_height = cam_data.sensor_height
- sensor_width = sensor_height * (res_x / res_y)
- focal = cam_data.lens
- frame_width = sensor_width / focal
- frame_height = sensor_height / focal
-
- edge_sample_grid = max(2, int(edge_sample_grid))
- patch_uv_polygons = []
- patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
- skipped_patches = 0
-
- print(f"Raycasting {nx*ny} patches...")
- for j in range(ny):
- v_start = j / ny
- v_end = (j+1) / ny
- for i in range(nx):
- u_start = i / nx
- u_end = (i+1) / nx
- uv_poly = sample_patch_uv_polygon(
- scene, depsgraph, cam, obj,
- u_start, u_end, v_start, v_end,
- frame_width, frame_height,
- edge_sample_grid=edge_sample_grid
- )
- if uv_poly is None:
- patch_uv_polygons.append(None)
- skipped_patches += 1
- continue
- patch_uv_polygons.append(uv_poly)
-
- neighbors = []
- if j > 0: neighbors.append(patch_colors[j-1, i])
- if i > 0: neighbors.append(patch_colors[j, i-1])
- color = np.random.rand(3) if len(neighbors)==0 else generate_valid_color(neighbors, r)
- patch_colors[j, i] = color
- print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
-
- # Create image – start with black background
- img_name = "PatchTexture"
- if img_name in bpy.data.images:
- bpy.data.images.remove(bpy.data.images[img_name])
- image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
- pixels = list(image.pixels)
-
- # Fill entire image with black (no mesh areas)
- fill_background(pixels, texture_width, texture_height, (0,0,0,1))
-
- # Rasterize all mesh UVs in white (visible + non‑visible parts)
- rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
-
- # Now draw the visible patches on top
- idx = 0
- for j in range(ny):
- for i in range(nx):
- uv_poly = patch_uv_polygons[idx]; idx += 1
- if uv_poly is None: continue
- color = patch_colors[j,i]
- rgba = (color[0], color[1], color[2], 1.0)
- for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
- fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
-
- # (after drawing all patches)
- if uv_centre is not None:
- draw_cross(pixels, texture_width, texture_height,
- uv_centre[0], uv_centre[1],
- cross_length, cross_thickness, cross_color)
-
- image.pixels[:] = pixels
- image.update()
- abs_path = bpy.path.abspath(output_path)
- image.filepath_raw = abs_path
- image.file_format = 'PNG'
- image.save()
- print(f"Patch texture saved to {abs_path}")
- return image
-
-# ----------------------------------------------------------------------
-# Mode 2: Black/White Checker Patches
-# ----------------------------------------------------------------------
-def generate_checker_texture(
- obj, cam, scene, depsgraph,
- nx=40, ny=40,
- texture_width=2048, texture_height=2048,
- output_path="//checker_texture.png", uv_centre=None, cross_length=40,
- cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
- gap_fill_px=6
-):
- cam_data = cam.data
- render = scene.render
- res_x = render.resolution_x
- res_y = render.resolution_y
- if cam_data.sensor_fit == 'HORIZONTAL':
- sensor_width = cam_data.sensor_width
- sensor_height = sensor_width / (res_x / res_y)
- else:
- sensor_height = cam_data.sensor_height
- sensor_width = sensor_height * (res_x / res_y)
- focal = cam_data.lens
- frame_width = sensor_width / focal
- frame_height = sensor_height / focal
-
- edge_sample_grid = max(2, int(edge_sample_grid))
- patch_uv_polygons = []
- patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
- skipped_patches = 0
-
- print(f"Raycasting {nx*ny} checker patches...")
- for j in range(ny):
- v_start = j / ny
- v_end = (j+1) / ny
- for i in range(nx):
- u_start = i / nx
- u_end = (i+1) / nx
- uv_poly = sample_patch_uv_polygon(
- scene, depsgraph, cam, obj,
- u_start, u_end, v_start, v_end,
- frame_width, frame_height,
- edge_sample_grid=edge_sample_grid
- )
- if uv_poly is None:
- patch_uv_polygons.append(None)
- skipped_patches += 1
- continue
- patch_uv_polygons.append(uv_poly)
- patch_colors[j, i] = checker_patch_color(i, j)
- print(f"Skipped {skipped_patches} checker patches with fewer than 3 mesh samples.")
-
- img_name = "CheckerTexture"
- if img_name in bpy.data.images:
- bpy.data.images.remove(bpy.data.images[img_name])
- image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
- pixels = list(image.pixels)
- mesh_mask = rasterize_mesh_mask(obj, texture_width, texture_height)
- patch_state = np.full((texture_height, texture_width), -1, dtype=np.int8)
-
- fill_background(pixels, texture_width, texture_height, (0,0,0,1))
- paint_mask_color(pixels, texture_width, texture_height, mesh_mask, (1,1,1,1))
-
- idx = 0
- for j in range(ny):
- for i in range(nx):
- uv_poly = patch_uv_polygons[idx]; idx += 1
- if uv_poly is None:
- continue
- color = patch_colors[j,i]
- rgba = (color[0], color[1], color[2], 1.0)
- patch_value = 1 if np.mean(color) >= 0.5 else 0
- for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
- fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
- fill_polygon_value(
- patch_state,
- texture_width,
- texture_height,
- pixel_poly,
- patch_value,
- )
-
- complete_checker_gaps(
- pixels, texture_width, texture_height, mesh_mask, patch_state, max_gap_px=gap_fill_px
- )
-
- if uv_centre is not None:
- draw_cross(pixels, texture_width, texture_height,
- uv_centre[0], uv_centre[1],
- cross_length, cross_thickness, cross_color)
-
- image.pixels[:] = pixels
- image.update()
- abs_path = bpy.path.abspath(output_path)
- image.filepath_raw = abs_path
- image.file_format = 'PNG'
- image.save()
- print(f"Checker texture saved to {abs_path}")
- return image
-
-# ----------------------------------------------------------------------
-# Mode 3: Circular Dot Markers
-# ----------------------------------------------------------------------
-def generate_dot_texture(
- obj, cam, scene, depsgraph,
- dot_diameter_pixels=12,
- spacing_pixels=40,
- dot_color=(0,0,0,1), # RGBA 0-1
- texture_width=2048, texture_height=2048,
- output_path="//dot_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
-):
- render = scene.render
- res_x = render.resolution_x
- res_y = render.resolution_y
- cam_data = cam.data
- if cam_data.sensor_fit == 'HORIZONTAL':
- sensor_width = cam_data.sensor_width
- sensor_height = sensor_width / (res_x / res_y)
- else:
- sensor_height = cam_data.sensor_height
- sensor_width = sensor_height * (res_x / res_y)
- focal = cam_data.lens
- frame_width = sensor_width / focal
- frame_height = sensor_height / focal
-
- margin = spacing_pixels // 2
- x_centers = np.arange(margin, res_x, spacing_pixels)
- y_centers = np.arange(margin, res_y, spacing_pixels)
- offset_pix = max(2, dot_diameter_pixels // 4)
- du_ndc = offset_pix / res_x
- dv_ndc = offset_pix / res_y
-
- dots = [] # (cx_uv, cy_uv, a_uv, b_uv, angle_uv)
- print(f"Raycasting {len(x_centers)*len(y_centers)} dot centers...")
- for y_pix in y_centers:
- v_ndc = y_pix / res_y
- for x_pix in x_centers:
- u_ndc = x_pix / res_x
- center_loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
- u_ndc, v_ndc, frame_width, frame_height)
- if center_loc is None: continue
- center_uv = get_uv_from_face(obj, face_idx, center_loc)
- if center_uv is None: continue
-
- offsets = [
- ( u_ndc + du_ndc, v_ndc),
- ( u_ndc - du_ndc, v_ndc),
- ( u_ndc, v_ndc + dv_ndc),
- ( u_ndc, v_ndc - dv_ndc)
- ]
- uv_offsets = []
- for (u_off, v_off) in offsets:
- uv_off = raycast_uv_from_camera(
- scene, depsgraph, cam, obj,
- u_off, v_off, frame_width, frame_height
- )
- uv_offsets.append(uv_off)
-
- scale_u = 1.0 / res_x
- scale_v = 1.0 / res_y
- r_pix = dot_diameter_pixels / 2.0
-
- right_uv, left_uv, up_uv, down_uv = uv_offsets
- if right_uv is not None and left_uv is not None:
- du_dx = (right_uv - left_uv) / (2 * offset_pix * scale_u)
- a_uv = r_pix * du_dx.length * scale_u
- elif right_uv is not None:
- du_dx = (right_uv - center_uv) / (offset_pix * scale_u)
- a_uv = r_pix * du_dx.length * scale_u
- elif left_uv is not None:
- du_dx = (center_uv - left_uv) / (offset_pix * scale_u)
- a_uv = r_pix * du_dx.length * scale_u
- else:
- du_dx = Vector((1.0, 0.0))
- a_uv = r_pix / texture_width
-
- if up_uv is not None and down_uv is not None:
- dv_dx = (up_uv - down_uv) / (2 * offset_pix * scale_v)
- b_uv = r_pix * dv_dx.length * scale_v
- elif up_uv is not None:
- dv_dx = (up_uv - center_uv) / (offset_pix * scale_v)
- b_uv = r_pix * dv_dx.length * scale_v
- elif down_uv is not None:
- dv_dx = (center_uv - down_uv) / (offset_pix * scale_v)
- b_uv = r_pix * dv_dx.length * scale_v
- else:
- b_uv = r_pix / texture_height
-
- a_uv = max(a_uv, 1.0 / texture_width)
- b_uv = max(b_uv, 1.0 / texture_height)
- angle = np.arctan2(du_dx.y, du_dx.x) if du_dx.length > 0 else 0.0
- dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle))
-
- print(f"Found {len(dots)} valid dots.")
-
- # Create image
- img_name = "DotTexture"
- if img_name in bpy.data.images:
- bpy.data.images.remove(bpy.data.images[img_name])
- image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
- pixels = list(image.pixels)
-
- # Black background (no mesh)
- fill_background(pixels, texture_width, texture_height, (0,0,0,1))
- # White for all mesh areas
- rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
- # Draw dots
- for (cx_uv, cy_uv, a_uv, b_uv, angle_uv) in dots:
- cx_px = cx_uv * (texture_width - 1)
- cy_px = cy_uv * (texture_height - 1)
- a_px = a_uv * texture_width
- b_px = b_uv * texture_height
- draw_ellipse(pixels, texture_width, texture_height,
- cx_px, cy_px, a_px, b_px, angle_uv, dot_color)
-
- if uv_centre is not None:
- draw_cross(pixels, texture_width, texture_height,
- uv_centre[0], uv_centre[1],
- cross_length, cross_thickness, cross_color)
-
- image.pixels[:] = pixels
- image.update()
- abs_path = bpy.path.abspath(output_path)
- image.filepath_raw = abs_path
- image.file_format = 'PNG'
- image.save()
- print(f"Dot texture saved to {abs_path}")
- return image
-
-# ----------------------------------------------------------------------
-# Main
-# ----------------------------------------------------------------------
-def main():
- # ==================== CONFIGURATION ====================
- MODE = "patches" # "patches", "checker", or "dots"
- OBJ_NAME = "FingerMesh0525.001"
- CAM_NAME = "Camera0607"
- TEXTURE_WIDTH = 1640*2
- TEXTURE_HEIGHT = 1600*2
- OUTPUT_NAMES = {
- "patches": "patch_texture.png",
- "checker": "checker_texture.png",
- "dots": "dot_texture.png",
- }
- OUTPUT_PATH = "//" + OUTPUT_NAMES.get(MODE, "texture.png")
-
- # Patch mode
- PATCH_NX = 80; PATCH_NY = 45; COLOR_R = 0.05
- EDGE_SAMPLE_GRID = 5
- # Checker mode
- CHECKER_NX = 40; CHECKER_NY = 40; CHECKER_GAP_FILL_PX = 6
- # Dot mode
- DOT_DIAMETER_PIX = 12; DOT_SPACING_PIX = 40; DOT_COLOR = (0,0,0,1)
- # Cross
- CROSS_LENGTH_PX = 40
- CROSS_THICKNESS_PX = 3
- CROSS_COLOR = (1, 0, 0, 1) # red cross
- # ======================================================
-
- start_time = time.time()
- scene = bpy.context.scene
- depsgraph = bpy.context.evaluated_depsgraph_get()
-
- if OBJ_NAME:
- obj = bpy.data.objects.get(OBJ_NAME)
- else:
- obj = bpy.context.active_object
- if obj is None or obj.type != 'MESH':
- raise ValueError("No valid mesh object found.")
-
- cam = bpy.data.objects.get(CAM_NAME) if CAM_NAME else scene.camera
- if cam is None or cam.type != 'CAMERA':
- raise ValueError("No camera found.")
-
- if not obj.data.uv_layers.active:
- obj.data.uv_layers.new(name="UVMap")
-
- # Compute UV centre
- uv_layer = obj.data.uv_layers.active
- uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
- uv_min = uv_coords.min(axis=0)
- uv_max = uv_coords.max(axis=0)
- uv_centre = (uv_min + uv_max) / 2.0
- print(f"UV centre: ({uv_centre[0]:.4f}, {uv_centre[1]:.4f})")
-
-
- # Compute UV bounding box centre
- me = obj.data
- uv_layer = me.uv_layers.active
- uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
- uv_min = uv_coords.min(axis=0)
- uv_max = uv_coords.max(axis=0)
- uv_centre = (uv_min + uv_max) / 2.0
- print(f"UV centre: {uv_centre[0]:.4f}, {uv_centre[1]:.4f}")
-
- if MODE == "patches":
- generate_patch_texture(
- obj, cam, scene, depsgraph,
- nx=PATCH_NX, ny=PATCH_NY, r=COLOR_R,
- texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
- output_path=OUTPUT_PATH,
- uv_centre=uv_centre,
- cross_length=CROSS_LENGTH_PX,
- cross_thickness=CROSS_THICKNESS_PX,
- cross_color=CROSS_COLOR,
- edge_sample_grid=EDGE_SAMPLE_GRID
- )
- elif MODE == "checker":
- generate_checker_texture(
- obj, cam, scene, depsgraph,
- nx=CHECKER_NX, ny=CHECKER_NY,
- texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
- output_path=OUTPUT_PATH,
- uv_centre=uv_centre,
- cross_length=CROSS_LENGTH_PX,
- cross_thickness=CROSS_THICKNESS_PX,
- cross_color=CROSS_COLOR,
- edge_sample_grid=EDGE_SAMPLE_GRID,
- gap_fill_px=CHECKER_GAP_FILL_PX
- )
- elif MODE == "dots":
- generate_dot_texture(
- obj, cam, scene, depsgraph,
- dot_diameter_pixels=DOT_DIAMETER_PIX,
- spacing_pixels=DOT_SPACING_PIX,
- dot_color=DOT_COLOR,
- texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
- output_path=OUTPUT_PATH,
- uv_centre=uv_centre,
- cross_length=CROSS_LENGTH_PX,
- cross_thickness=CROSS_THICKNESS_PX,
- cross_color=CROSS_COLOR
- )
- else:
- raise ValueError(f"Unknown MODE: {MODE!r}. Use 'patches', 'checker', or 'dots'.")
-
- print(f"Completed in {time.time()-start_time:.2f} seconds.")
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/tools/3d_patch_generation_blender/random_patch_dd02c_fucidals.py b/tools/3d_patch_generation_blender/random_patch_dd02c_fucidals.py
deleted file mode 100644
index 06ce878..0000000
--- a/tools/3d_patch_generation_blender/random_patch_dd02c_fucidals.py
+++ /dev/null
@@ -1,1196 +0,0 @@
-"""
-Texture Generator for Arbitrary 3D Surfaces (Blender Script)
-- Mode: "patches" (colored patches), "checker" (black/white patches),
- or "dots" (circular markers)
-- Mesh UVs are filled white so that non‑visible parts become white.
-- Texture background (no‑mesh areas) stays black.
-"""
-
-import bpy
-import numpy as np
-import os
-import time
-from mathutils import Vector
-
-# ----------------------------------------------------------------------
-# Common Utilities
-# ----------------------------------------------------------------------
-def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
- """
- Draw a cross (plus sign) in pixel space.
- cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left.
- length_px: total length of each arm (pixels).
- thickness_px: line thickness (odd number gives symmetric result).
- """
- # Convert UV centre to pixel coordinates (y from bottom)
- cx = int(round(cx_uv * (width - 1)))
- cy = int(round(cy_uv * (height - 1)))
-
- half_len = length_px // 2
- half_thick = thickness_px // 2
-
- # Horizontal arm
- x0 = max(0, cx - half_len)
- x1 = min(width - 1, cx + half_len)
- y0 = max(0, cy - half_thick)
- y1 = min(height - 1, cy + half_thick)
- for y in range(y0, y1 + 1):
- for x in range(x0, x1 + 1):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
- # Vertical arm
- x0 = max(0, cx - half_thick)
- x1 = min(width - 1, cx + half_thick)
- y0 = max(0, cy - half_len)
- y1 = min(height - 1, cy + half_len)
- for y in range(y0, y1 + 1):
- for x in range(x0, x1 + 1):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
-def fill_background(pixels, width, height, color):
- """Fill the entire image with a solid RGBA color (0-1 floats)."""
- r, g, b, a = color
- for y in range(height):
- for x in range(width):
- idx = (y * width + x) * 4
- pixels[idx] = r
- pixels[idx+1] = g
- pixels[idx+2] = b
- pixels[idx+3] = a
-
-def fill_polygon(image, pixels, width, height, polygon, color):
- """Fill a polygon in the pixel array (flattened, RGBA)."""
- poly = [(int(round(x)), int(round(y))) for x, y in polygon]
- if len(poly) < 3:
- return
- min_x = max(0, min(p[0] for p in poly))
- max_x = min(width-1, max(p[0] for p in poly))
- min_y = max(0, min(p[1] for p in poly))
- max_y = min(height-1, max(p[1] for p in poly))
- for y in range(min_y, max_y + 1):
- intersections = []
- for i in range(len(poly)):
- x1, y1 = poly[i]
- x2, y2 = poly[(i+1) % len(poly)]
- if y1 == y2:
- continue
- if (y1 <= y < y2) or (y2 <= y < y1):
- t = (y - y1) / (y2 - y1)
- x = x1 + t * (x2 - x1)
- intersections.append(x)
- intersections.sort()
- for k in range(0, len(intersections), 2):
- if k+1 >= len(intersections):
- break
- x_start = int(round(intersections[k]))
- x_end = int(round(intersections[k+1]))
- x_start = max(0, min(width-1, x_start))
- x_end = max(0, min(width-1, x_end))
- for x in range(x_start, x_end + 1):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
-def fill_polygon_value(grid, width, height, polygon, value):
- """Fill a polygon into a 2D scalar grid."""
- poly = [(int(round(x)), int(round(y))) for x, y in polygon]
- if len(poly) < 3:
- return
- min_x = max(0, min(p[0] for p in poly))
- max_x = min(width - 1, max(p[0] for p in poly))
- min_y = max(0, min(p[1] for p in poly))
- max_y = min(height - 1, max(p[1] for p in poly))
- for y in range(min_y, max_y + 1):
- intersections = []
- for i in range(len(poly)):
- x1, y1 = poly[i]
- x2, y2 = poly[(i + 1) % len(poly)]
- if y1 == y2:
- continue
- if (y1 <= y < y2) or (y2 <= y < y1):
- t = (y - y1) / (y2 - y1)
- x = x1 + t * (x2 - x1)
- intersections.append(x)
- intersections.sort()
- for k in range(0, len(intersections), 2):
- if k + 1 >= len(intersections):
- break
- x_start = int(round(intersections[k]))
- x_end = int(round(intersections[k + 1]))
- x_start = max(0, min(width - 1, x_start))
- x_end = max(0, min(width - 1, x_end))
- grid[y, x_start:x_end + 1] = value
-
-def unwrap_periodic_values(values):
- """
- Unwrap periodic UV coordinates so points straddling the 0/1 seam stay local.
- """
- arr = np.asarray(values, dtype=np.float64).copy()
- if arr.size == 0:
- return arr
- if arr.max() - arr.min() > 0.5:
- arr[arr < 0.5] += 1.0
- return arr
-
-def wrap_uv_polygon_for_raster(uv_poly):
- """
- Return a seam-aware version of a UV polygon.
-
- UV islands may cross the 0/1 seam. If we rasterize those raw coordinates as a
- single polygon, Blender's repeated UVs turn into a long diagonal triangle
- across the texture. We unwrap seam-crossing polygons first, then draw wrapped
- copies in the texture tile(s) they overlap.
- """
- if len(uv_poly) < 3:
- return []
-
- uv_arr = np.array([(float(u), float(v)) for u, v in uv_poly], dtype=np.float64)
- uv_arr[:, 0] = unwrap_periodic_values(uv_arr[:, 0])
- uv_arr[:, 1] = unwrap_periodic_values(uv_arr[:, 1])
- return [tuple(pt) for pt in uv_arr]
-
-def uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
- """
- Convert one UV polygon into one or more pixel polygons, duplicating seam-
- crossing polygons into adjacent tiles so rasterization stays local.
- """
- unwrapped = wrap_uv_polygon_for_raster(uv_poly)
- if len(unwrapped) < 3:
- return []
-
- u_vals = [u for u, _ in unwrapped]
- v_vals = [v for _, v in unwrapped]
- u_shift_start = int(np.floor(min(u_vals)))
- u_shift_end = int(np.floor(max(u_vals)))
- v_shift_start = int(np.floor(min(v_vals)))
- v_shift_end = int(np.floor(max(v_vals)))
-
- pixel_polys = []
- for u_shift in range(u_shift_start, u_shift_end + 1):
- for v_shift in range(v_shift_start, v_shift_end + 1):
- shifted = [(u - u_shift, v - v_shift) for u, v in unwrapped]
- shifted_u = [u for u, _ in shifted]
- shifted_v = [v for _, v in shifted]
- if max(shifted_u) < 0.0 or min(shifted_u) > 1.0:
- continue
- if max(shifted_v) < 0.0 or min(shifted_v) > 1.0:
- continue
- pixel_polys.append([
- (u * (tex_width - 1), v * (tex_height - 1))
- for u, v in shifted
- ])
- return pixel_polys
-
-def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
- """Filled ellipse with semi‑axes a,b (pixels), rotation angle (rad)."""
- cos_a = np.cos(angle)
- sin_a = np.sin(angle)
- rx = abs(a * cos_a) + abs(b * sin_a)
- ry = abs(a * sin_a) + abs(b * cos_a)
- x_min = int(max(0, cx - rx))
- x_max = int(min(width-1, cx + rx))
- y_min = int(max(0, cy - ry))
- y_max = int(min(height-1, cy + ry))
- for y in range(y_min, y_max + 1):
- for x in range(x_min, x_max + 1):
- dx = x - cx
- dy = y - cy
- u = dx * cos_a + dy * sin_a
- v = -dx * sin_a + dy * cos_a
- if (u/a)**2 + (v/b)**2 <= 1.0:
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = color
-
-def wrap_uv_near_reference(uv, reference_uv):
- """Wrap a UV so it stays closest to a reference UV across periodic seams."""
- du = uv.x - reference_uv.x
- dv = uv.y - reference_uv.y
- if du > 0.5:
- du -= 1.0
- elif du < -0.5:
- du += 1.0
- if dv > 0.5:
- dv -= 1.0
- elif dv < -0.5:
- dv += 1.0
- return Vector((reference_uv.x + du, reference_uv.y + dv))
-
-def wrapped_uv_distance_sq(uv_a, uv_b):
- """Squared UV distance on a periodic 0..1 texture domain."""
- du = abs((uv_a.x % 1.0) - (uv_b.x % 1.0))
- dv = abs((uv_a.y % 1.0) - (uv_b.y % 1.0))
- du = min(du, 1.0 - du)
- dv = min(dv, 1.0 - dv)
- return du * du + dv * dv
-
-def draw_wrapped_ellipse_uv(
- pixels, width, height,
- cx_uv, cy_uv, a_uv, b_uv, angle, color
-):
- """
- Draw an ellipse in UV space, duplicating it across the texture seam if needed.
- """
- base_u = cx_uv % 1.0
- base_v = cy_uv % 1.0
- for u_shift in (-1, 0, 1):
- for v_shift in (-1, 0, 1):
- shifted_u = base_u + u_shift
- shifted_v = base_v + v_shift
- if shifted_u + a_uv < 0.0 or shifted_u - a_uv > 1.0:
- continue
- if shifted_v + b_uv < 0.0 or shifted_v - b_uv > 1.0:
- continue
- cx_px = shifted_u * (width - 1)
- cy_px = shifted_v * (height - 1)
- a_px = a_uv * width
- b_px = b_uv * height
- draw_ellipse(pixels, width, height, cx_px, cy_px, a_px, b_px, angle, color)
-
-def draw_wrapped_outlined_ellipse_uv(
- pixels, width, height,
- cx_uv, cy_uv, a_uv, b_uv, angle,
- fill_color, outline_color, outline_px=2
-):
- """
- Draw a seam-safe ellipse with an outline for visibility on bright textures.
- """
- outline_au = a_uv + (outline_px / width)
- outline_bv = b_uv + (outline_px / height)
- draw_wrapped_ellipse_uv(
- pixels, width, height,
- cx_uv, cy_uv, outline_au, outline_bv, angle, outline_color
- )
- draw_wrapped_ellipse_uv(
- pixels, width, height,
- cx_uv, cy_uv, a_uv, b_uv, angle, fill_color
- )
-
-def get_camera_frame(cam, scene):
- """Return camera frame dimensions and render resolution."""
- render = scene.render
- res_x = render.resolution_x
- res_y = render.resolution_y
- cam_data = cam.data
- if cam_data.sensor_fit == 'HORIZONTAL':
- sensor_width = cam_data.sensor_width
- sensor_height = sensor_width / (res_x / res_y)
- else:
- sensor_height = cam_data.sensor_height
- sensor_width = sensor_height * (res_x / res_y)
- focal = cam_data.lens
- frame_width = sensor_width / focal
- frame_height = sensor_height / focal
- return frame_width, frame_height, res_x, res_y
-
-def estimate_dot_uv_parameters(
- scene, depsgraph, cam, obj,
- u_ndc, v_ndc, center_uv,
- frame_width, frame_height, res_x, res_y,
- texture_width, texture_height, dot_diameter_pixels
-):
- """
- Estimate a UV-space dot size for a screen-space circular marker.
-
- We still sample local UV distortion from camera-space offsets, but collapse
- the result to one isotropic UV radius so the marker stays circular on the
- texture map instead of turning into an ellipse near the sides.
- """
- offset_pix = max(2, dot_diameter_pixels // 4)
- du_ndc = offset_pix / res_x
- dv_ndc = offset_pix / res_y
- offsets = [
- (u_ndc + du_ndc, v_ndc),
- (u_ndc - du_ndc, v_ndc),
- (u_ndc, v_ndc + dv_ndc),
- (u_ndc, v_ndc - dv_ndc),
- ]
-
- uv_offsets = []
- for u_off, v_off in offsets:
- uv_off = raycast_uv_from_camera(
- scene, depsgraph, cam, obj, u_off, v_off, frame_width, frame_height
- )
- if uv_off is not None:
- uv_off = wrap_uv_near_reference(uv_off, center_uv)
- uv_offsets.append(uv_off)
-
- scale_u = 1.0 / res_x
- scale_v = 1.0 / res_y
- r_pix = dot_diameter_pixels / 2.0
-
- right_uv, left_uv, up_uv, down_uv = uv_offsets
- if right_uv is not None and left_uv is not None:
- du_dx = (right_uv - left_uv) / (2 * offset_pix * scale_u)
- a_uv = r_pix * du_dx.length * scale_u
- elif right_uv is not None:
- du_dx = (right_uv - center_uv) / (offset_pix * scale_u)
- a_uv = r_pix * du_dx.length * scale_u
- elif left_uv is not None:
- du_dx = (center_uv - left_uv) / (offset_pix * scale_u)
- a_uv = r_pix * du_dx.length * scale_u
- else:
- du_dx = Vector((1.0, 0.0))
- a_uv = r_pix / texture_width
-
- if up_uv is not None and down_uv is not None:
- dv_dx = (up_uv - down_uv) / (2 * offset_pix * scale_v)
- b_uv = r_pix * dv_dx.length * scale_v
- elif up_uv is not None:
- dv_dx = (up_uv - center_uv) / (offset_pix * scale_v)
- b_uv = r_pix * dv_dx.length * scale_v
- elif down_uv is not None:
- dv_dx = (center_uv - down_uv) / (offset_pix * scale_v)
- b_uv = r_pix * dv_dx.length * scale_v
- else:
- b_uv = r_pix / texture_height
-
- a_uv = max(a_uv, 1.0 / texture_width)
- b_uv = max(b_uv, 1.0 / texture_height)
-
- # Keep the marker circular in the texture domain, but let its radius grow
- # with the stronger of the local UV-space scales.
- radius_uv = max(a_uv, b_uv)
- return radius_uv, radius_uv, 0.0
-
-def has_visible_margin(
- scene, depsgraph, cam, obj,
- u_ndc, v_ndc, frame_width, frame_height,
- radius_ndc_u, radius_ndc_v,
- extra_margin_px_x=0.0, extra_margin_px_y=0.0,
- res_x=1, res_y=1,
- angular_samples=12
-):
- """
- Check whether a candidate center has enough visible-mesh clearance around it.
-
- We probe a ring around the candidate in camera space. If those neighboring
- rays also hit the mesh, the dot is less likely to cross or touch the visible
- silhouette.
- """
- test_radius_u = radius_ndc_u + (extra_margin_px_x / max(res_x, 1))
- test_radius_v = radius_ndc_v + (extra_margin_px_y / max(res_y, 1))
- for k in range(max(angular_samples, 4)):
- angle = 2.0 * np.pi * k / angular_samples
- test_u = u_ndc + test_radius_u * np.cos(angle)
- test_v = v_ndc + test_radius_v * np.sin(angle)
- hit_loc, _ = raycast_from_camera(
- scene, depsgraph, cam, obj, test_u, test_v, frame_width, frame_height
- )
- if hit_loc is None:
- return False
- return True
-
-def generate_visible_fiducial_dots(
- scene, depsgraph, cam, obj,
- frame_width, frame_height, res_x, res_y,
- texture_width, texture_height,
- grid_nx, grid_ny, dot_diameter_pixels,
- visibility_sample_nx=96, visibility_sample_ny=96,
- edge_margin_pixels=0.0
-):
- """
- Build an approximately even grid of fiducials over the visible mesh area.
-
- The grid is spaced in camera space over the visible mesh bounding box, then
- snapped to the nearest visible raycast sample so every fiducial lands on the
- actually visible surface.
- """
- if grid_nx <= 0 or grid_ny <= 0:
- return []
-
- visibility_sample_nx = max(int(visibility_sample_nx), grid_nx * 8, 16)
- visibility_sample_ny = max(int(visibility_sample_ny), grid_ny * 8, 16)
- radius_ndc_u = (dot_diameter_pixels * 0.5) / res_x
- radius_ndc_v = (dot_diameter_pixels * 0.5) / res_y
- inset_ndc_u = radius_ndc_u + (edge_margin_pixels / max(res_x, 1))
- inset_ndc_v = radius_ndc_v + (edge_margin_pixels / max(res_y, 1))
-
- all_visible_samples = []
- interior_visible_samples = []
- for j in range(visibility_sample_ny):
- v_ndc = (j + 0.5) / visibility_sample_ny
- for i in range(visibility_sample_nx):
- u_ndc = (i + 0.5) / visibility_sample_nx
- center_loc, face_idx = raycast_from_camera(
- scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
- )
- if center_loc is None:
- continue
- center_uv = get_uv_from_face(obj, face_idx, center_loc)
- if center_uv is None:
- continue
- all_visible_samples.append((u_ndc, v_ndc, center_uv))
- if not has_visible_margin(
- scene, depsgraph, cam, obj,
- u_ndc, v_ndc, frame_width, frame_height,
- radius_ndc_u, radius_ndc_v,
- extra_margin_px_x=edge_margin_pixels,
- extra_margin_px_y=edge_margin_pixels,
- res_x=res_x,
- res_y=res_y
- ):
- continue
- interior_visible_samples.append((u_ndc, v_ndc, center_uv))
-
- if not all_visible_samples:
- print("Warning: No visible mesh samples found for fiducials.")
- return []
- if not interior_visible_samples:
- print("Warning: No interior visible mesh samples found for fiducials.")
- return []
-
- all_u_values = np.array([sample[0] for sample in all_visible_samples], dtype=np.float64)
- all_v_values = np.array([sample[1] for sample in all_visible_samples], dtype=np.float64)
- u_min = float(all_u_values.min() + inset_ndc_u)
- u_max = float(all_u_values.max() - inset_ndc_u)
- v_min = float(all_v_values.min() + inset_ndc_v)
- v_max = float(all_v_values.max() - inset_ndc_v)
-
- interior_u_values = np.array([sample[0] for sample in interior_visible_samples], dtype=np.float64)
- interior_v_values = np.array([sample[1] for sample in interior_visible_samples], dtype=np.float64)
- u_min = max(u_min, float(interior_u_values.min()))
- u_max = min(u_max, float(interior_u_values.max()))
- v_min = max(v_min, float(interior_v_values.min()))
- v_max = min(v_max, float(interior_v_values.max()))
-
- if u_min > u_max:
- mid_u = float(interior_u_values.mean())
- u_min = mid_u
- u_max = mid_u
- if v_min > v_max:
- mid_v = float(interior_v_values.mean())
- v_min = mid_v
- v_max = mid_v
-
- if grid_nx == 1:
- target_us = np.array([(u_min + u_max) * 0.5], dtype=np.float64)
- else:
- target_us = np.linspace(u_min, u_max, grid_nx)
-
- if grid_ny == 1:
- target_vs = np.array([(v_min + v_max) * 0.5], dtype=np.float64)
- else:
- target_vs = np.linspace(v_min, v_max, grid_ny)
-
- dots = []
- selected_uvs = []
- used_indices = set()
- min_sep_u = max(2.0 * dot_diameter_pixels / texture_width, 1.0 / visibility_sample_nx)
- min_sep_v = max(2.0 * dot_diameter_pixels / texture_height, 1.0 / visibility_sample_ny)
- min_sep_sq = min_sep_u * min_sep_u + min_sep_v * min_sep_v
- for target_v in target_vs:
- for target_u in target_us:
- candidate_indices = []
- for idx, (u_ndc, v_ndc, center_uv) in enumerate(interior_visible_samples):
- if idx in used_indices:
- continue
- dist = (u_ndc - target_u) ** 2 + (v_ndc - target_v) ** 2
- candidate_indices.append((dist, idx))
- candidate_indices.sort(key=lambda item: item[0])
-
- chosen_idx = None
- fallback_idx = None
- for _, idx in candidate_indices:
- if fallback_idx is None:
- fallback_idx = idx
- _, _, center_uv = interior_visible_samples[idx]
- if all(wrapped_uv_distance_sq(center_uv, prev_uv) >= min_sep_sq for prev_uv in selected_uvs):
- chosen_idx = idx
- break
-
- if chosen_idx is None:
- chosen_idx = fallback_idx
- if chosen_idx is None:
- continue
-
- used_indices.add(chosen_idx)
- u_ndc, v_ndc, center_uv = interior_visible_samples[chosen_idx]
- a_uv, b_uv, angle = estimate_dot_uv_parameters(
- scene, depsgraph, cam, obj,
- u_ndc, v_ndc, center_uv,
- frame_width, frame_height, res_x, res_y,
- texture_width, texture_height, dot_diameter_pixels
- )
- dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle))
- selected_uvs.append(center_uv)
-
- print(
- f"Generated {len(dots)} fiducial dots from a {grid_nx}x{grid_ny} target grid "
- f"using {len(interior_visible_samples)}/{len(all_visible_samples)} interior samples."
- )
- return dots
-
-def get_uv_from_face(obj, face_index, location):
- """World‑space location → UV coordinate on the mesh face."""
- me = obj.data
- local_loc = obj.matrix_world.inverted() @ location
- poly = me.polygons[face_index]
- vert_indices = poly.vertices
- loop_indices = poly.loop_indices
- if len(vert_indices) < 3:
- return None
- v0 = me.vertices[vert_indices[0]].co
- v1 = me.vertices[vert_indices[1]].co
- v2 = me.vertices[vert_indices[2]].co
- uv_layer = me.uv_layers.active.data
- uv0 = uv_layer[loop_indices[0]].uv
- uv1 = uv_layer[loop_indices[1]].uv
- uv2 = uv_layer[loop_indices[2]].uv
- def tri_area(a, b, c):
- return 0.5 * ((b - a).cross(c - a)).length
- total_area = tri_area(v0, v1, v2)
- if total_area == 0:
- return None
- w0 = tri_area(local_loc, v1, v2) / total_area
- w1 = tri_area(local_loc, v2, v0) / total_area
- w2 = 1.0 - w0 - w1
- uv = w0 * uv0 + w1 * uv1 + w2 * uv2
- return Vector((uv.x, uv.y))
-
-def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
- """Camera raycast through normalised sensor coordinates (0‑1)."""
- sx = (u_ndc - 0.5) * frame_width
- sy = (v_ndc - 0.5) * frame_height
- direction_cam = Vector((sx, sy, -1.0))
- direction_world = cam.matrix_world.to_quaternion() @ direction_cam
- direction_world.normalize()
- origin = cam.matrix_world.translation
- hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
- depsgraph, origin, direction_world)
- if hit and hit_obj == obj:
- return location, face_index
- return None, None
-
-def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
- """Camera raycast through normalized sensor coordinates and return UV."""
- loc, face_idx = raycast_from_camera(
- scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
- )
- if loc is None:
- return None
- return get_uv_from_face(obj, face_idx, loc)
-
-def convex_hull_uv(points):
- """Return UV points ordered around their 2D convex hull."""
- unwrapped = wrap_uv_polygon_for_raster([(p.x, p.y) for p in points])
- unique = sorted(set(unwrapped))
- if len(unique) < 3:
- return None
-
- def cross(o, a, b):
- return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
-
- lower = []
- for p in unique:
- while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
- lower.pop()
- lower.append(p)
-
- upper = []
- for p in reversed(unique):
- while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
- upper.pop()
- upper.append(p)
-
- hull = lower[:-1] + upper[:-1]
- if len(hull) < 3:
- return None
- return [Vector((u, v)) for u, v in hull]
-
-def sample_patch_uv_polygon(
- scene, depsgraph, cam, obj,
- u_start, u_end, v_start, v_end,
- frame_width, frame_height,
- edge_sample_grid=5
-):
- """
- Build a UV polygon for one camera-space patch.
-
- Full interior cells use the four corners. Edge cells that only partially hit
- the mesh are sampled more densely and converted to a convex hull so they are
- not dropped just because some corners miss the silhouette.
- """
- corners_ndc = [
- (u_start, v_start),
- (u_end, v_start),
- (u_end, v_end),
- (u_start, v_end),
- ]
-
- corner_uvs = []
- for u_ndc, v_ndc in corners_ndc:
- uv = raycast_uv_from_camera(
- scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
- )
- if uv is not None:
- corner_uvs.append(uv)
-
- if len(corner_uvs) >= 3:
- return corner_uvs
-
- sampled_uvs = list(corner_uvs)
- for sy in range(edge_sample_grid):
- v_ndc = v_start + (v_end - v_start) * sy / (edge_sample_grid - 1)
- for sx in range(edge_sample_grid):
- u_ndc = u_start + (u_end - u_start) * sx / (edge_sample_grid - 1)
- uv = raycast_uv_from_camera(
- scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
- )
- if uv is not None:
- sampled_uvs.append(uv)
-
- return convex_hull_uv(sampled_uvs)
-
-def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)):
- """
- Fill all triangles of the active UV map with 'color' (default white).
- This marks every part of the mesh, visible or not, as white.
- """
- me = obj.data
- uv_layer = me.uv_layers.active
- if not uv_layer:
- print("Warning: No active UV layer – mesh rasterization skipped.")
- return
- uv_data = uv_layer.data
- for poly in me.polygons:
- # Get loop indices for this polygon
- loops = poly.loop_indices
- # Build UV polygon
- uv_poly = []
- for loop_idx in loops:
- uv = uv_data[loop_idx].uv
- uv_poly.append((uv.x, uv.y))
- # Convert UV (0‑1) to pixel coordinates
- for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
- fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
-
-def rasterize_mesh_mask(obj, tex_width, tex_height):
- """Rasterize the active UV map to a boolean mesh mask."""
- mask = np.zeros((tex_height, tex_width), dtype=bool)
- me = obj.data
- uv_layer = me.uv_layers.active
- if not uv_layer:
- return mask
- uv_data = uv_layer.data
- for poly in me.polygons:
- uv_poly = []
- for loop_idx in poly.loop_indices:
- uv = uv_data[loop_idx].uv
- uv_poly.append((uv.x, uv.y))
- for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
- fill_polygon_value(mask, tex_width, tex_height, pixel_poly, True)
- return mask
-
-def paint_mask_color(pixels, width, height, mask, color):
- """Paint all True pixels of a boolean mask with an RGBA color."""
- r, g, b, a = color
- ys, xs = np.nonzero(mask)
- for y, x in zip(ys, xs):
- idx = (y * width + x) * 4
- pixels[idx:idx+4] = (r, g, b, a)
-
-def complete_checker_gaps(pixels, width, height, mesh_mask, patch_state, max_gap_px=6):
- """
- Fill small white slivers inside the mesh by expanding nearby checker colors.
-
- `patch_state`: -1 = unassigned, 0 = black patch, 1 = white patch.
- """
- for _ in range(max_gap_px):
- candidate = mesh_mask & (patch_state < 0)
- if not np.any(candidate):
- break
-
- black_counts = np.zeros_like(patch_state, dtype=np.uint8)
- white_counts = np.zeros_like(patch_state, dtype=np.uint8)
-
- for dy in (-1, 0, 1):
- for dx in (-1, 0, 1):
- if dx == 0 and dy == 0:
- continue
-
- src_y0 = max(0, -dy)
- src_y1 = height - max(0, dy)
- src_x0 = max(0, -dx)
- src_x1 = width - max(0, dx)
- dst_y0 = max(0, dy)
- dst_y1 = height - max(0, -dy)
- dst_x0 = max(0, dx)
- dst_x1 = width - max(0, -dx)
-
- neighbor_vals = patch_state[src_y0:src_y1, src_x0:src_x1]
- black_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 0)
- white_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 1)
-
- fillable = candidate & ((black_counts + white_counts) > 0)
- if not np.any(fillable):
- break
-
- patch_state[fillable] = np.where(
- black_counts[fillable] > white_counts[fillable], 0, 1
- ).astype(np.int8)
-
- paint_mask_color(pixels, width, height, patch_state == 0, (0.0, 0.0, 0.0, 1.0))
- paint_mask_color(pixels, width, height, patch_state == 1, (1.0, 1.0, 1.0, 1.0))
-
-# ----------------------------------------------------------------------
-# Color generation (for patch mode)
-# ----------------------------------------------------------------------
-def merge_intervals(intervals):
- if not intervals: return []
- intervals.sort(key=lambda x: x[0])
- merged = [intervals[0]]
- for current in intervals[1:]:
- last = merged[-1]
- if current[0] <= last[1]:
- merged[-1] = (last[0], max(last[1], current[1]))
- else:
- merged.append(current)
- return merged
-
-def get_allowed_intervals(neighbor_values, delta):
- forbidden = []
- for c in neighbor_values:
- low = max(0, c - delta)
- high = min(1, c + delta)
- forbidden.append((low, high))
- merged = merge_intervals(forbidden)
- allowed = []
- if merged:
- if merged[0][0] > 0:
- allowed.append((0, merged[0][0]))
- for i in range(1, len(merged)):
- allowed.append((merged[i-1][1], merged[i][0]))
- if merged[-1][1] < 1:
- allowed.append((merged[-1][1], 1))
- else:
- allowed.append((0, 1))
- return allowed
-
-def sample_from_allowed(allowed):
- lengths = [end - start for start, end in allowed]
- total_length = sum(lengths)
- if total_length <= 0: return None
- point = np.random.rand() * total_length
- cumulative = 0
- for (start, end), length in zip(allowed, lengths):
- cumulative += length
- if point <= cumulative:
- return np.random.uniform(start, end)
- return allowed[-1][0]
-
-def sample_channel(neighbor_values, delta):
- allowed = get_allowed_intervals(neighbor_values, delta)
- if not allowed: return None
- return sample_from_allowed(allowed)
-
-def generate_valid_color(neighbors, r):
- delta = np.sqrt(r)
- color = np.zeros(3)
- for channel in range(3):
- neighbor_channel = [n[channel] for n in neighbors]
- sampled = sample_channel(neighbor_channel, delta)
- if sampled is None:
- sampled = np.random.rand()
- color[channel] = sampled
- return color
-
-def checker_patch_color(i, j):
- """Return a black/white checker color for patch index (i, j)."""
- if (i + j) % 2 == 0:
- return np.array([1.0, 1.0, 1.0], dtype=np.float32)
- return np.array([0.0, 0.0, 0.0], dtype=np.float32)
-
-# ----------------------------------------------------------------------
-# Mode 1: Colored Patches
-# ----------------------------------------------------------------------
-def generate_patch_texture(
- obj, cam, scene, depsgraph,
- nx=40, ny=30, r=0.05,
- texture_width=2048, texture_height=2048,
- output_path="//patch_texture.png", uv_centre=None, cross_length=40,
- cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
- fiducial_nx=0, fiducial_ny=0, fiducial_diameter_pixels=18,
- fiducial_color=(1,1,1,1), fiducial_outline_color=(0,0,0,1),
- fiducial_outline_px=2, fiducial_edge_margin_pixels=0.0
-):
- frame_width, frame_height, res_x, res_y = get_camera_frame(cam, scene)
-
- edge_sample_grid = max(2, int(edge_sample_grid))
- patch_uv_polygons = []
- patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
- skipped_patches = 0
-
- print(f"Raycasting {nx*ny} patches...")
- for j in range(ny):
- v_start = j / ny
- v_end = (j+1) / ny
- for i in range(nx):
- u_start = i / nx
- u_end = (i+1) / nx
- uv_poly = sample_patch_uv_polygon(
- scene, depsgraph, cam, obj,
- u_start, u_end, v_start, v_end,
- frame_width, frame_height,
- edge_sample_grid=edge_sample_grid
- )
- if uv_poly is None:
- patch_uv_polygons.append(None)
- skipped_patches += 1
- continue
- patch_uv_polygons.append(uv_poly)
-
- neighbors = []
- if j > 0: neighbors.append(patch_colors[j-1, i])
- if i > 0: neighbors.append(patch_colors[j, i-1])
- color = np.random.rand(3) if len(neighbors)==0 else generate_valid_color(neighbors, r)
- patch_colors[j, i] = color
- print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
-
- # Create image – start with black background
- img_name = "PatchTexture"
- if img_name in bpy.data.images:
- bpy.data.images.remove(bpy.data.images[img_name])
- image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
- pixels = list(image.pixels)
-
- # Fill entire image with black (no mesh areas)
- fill_background(pixels, texture_width, texture_height, (0,0,0,1))
-
- # Rasterize all mesh UVs in white (visible + non‑visible parts)
- rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
-
- # Now draw the visible patches on top
- idx = 0
- for j in range(ny):
- for i in range(nx):
- uv_poly = patch_uv_polygons[idx]; idx += 1
- if uv_poly is None: continue
- color = patch_colors[j,i]
- rgba = (color[0], color[1], color[2], 1.0)
- for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
- fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
-
- if fiducial_nx > 0 and fiducial_ny > 0:
- fiducial_dots = generate_visible_fiducial_dots(
- scene, depsgraph, cam, obj,
- frame_width, frame_height, res_x, res_y,
- texture_width, texture_height,
- fiducial_nx, fiducial_ny, fiducial_diameter_pixels,
- edge_margin_pixels=fiducial_edge_margin_pixels
- )
- for cx_uv, cy_uv, a_uv, b_uv, angle_uv in fiducial_dots:
- draw_wrapped_outlined_ellipse_uv(
- pixels, texture_width, texture_height,
- cx_uv, cy_uv, a_uv, b_uv, angle_uv,
- fiducial_color, fiducial_outline_color, fiducial_outline_px
- )
-
- if uv_centre is not None:
- draw_cross(pixels, texture_width, texture_height,
- uv_centre[0], uv_centre[1],
- cross_length, cross_thickness, cross_color)
-
- image.pixels[:] = pixels
- image.update()
- abs_path = bpy.path.abspath(output_path)
- image.filepath_raw = abs_path
- image.file_format = 'PNG'
- image.save()
- print(f"Patch texture saved to {abs_path}")
- return image
-
-# ----------------------------------------------------------------------
-# Mode 2: Black/White Checker Patches
-# ----------------------------------------------------------------------
-def generate_checker_texture(
- obj, cam, scene, depsgraph,
- nx=40, ny=40,
- texture_width=2048, texture_height=2048,
- output_path="//checker_texture.png", uv_centre=None, cross_length=40,
- cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
- gap_fill_px=6
-):
- cam_data = cam.data
- render = scene.render
- res_x = render.resolution_x
- res_y = render.resolution_y
- if cam_data.sensor_fit == 'HORIZONTAL':
- sensor_width = cam_data.sensor_width
- sensor_height = sensor_width / (res_x / res_y)
- else:
- sensor_height = cam_data.sensor_height
- sensor_width = sensor_height * (res_x / res_y)
- focal = cam_data.lens
- frame_width = sensor_width / focal
- frame_height = sensor_height / focal
-
- edge_sample_grid = max(2, int(edge_sample_grid))
- patch_uv_polygons = []
- patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
- skipped_patches = 0
-
- print(f"Raycasting {nx*ny} checker patches...")
- for j in range(ny):
- v_start = j / ny
- v_end = (j+1) / ny
- for i in range(nx):
- u_start = i / nx
- u_end = (i+1) / nx
- uv_poly = sample_patch_uv_polygon(
- scene, depsgraph, cam, obj,
- u_start, u_end, v_start, v_end,
- frame_width, frame_height,
- edge_sample_grid=edge_sample_grid
- )
- if uv_poly is None:
- patch_uv_polygons.append(None)
- skipped_patches += 1
- continue
- patch_uv_polygons.append(uv_poly)
- patch_colors[j, i] = checker_patch_color(i, j)
- print(f"Skipped {skipped_patches} checker patches with fewer than 3 mesh samples.")
-
- img_name = "CheckerTexture"
- if img_name in bpy.data.images:
- bpy.data.images.remove(bpy.data.images[img_name])
- image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
- pixels = list(image.pixels)
- mesh_mask = rasterize_mesh_mask(obj, texture_width, texture_height)
- patch_state = np.full((texture_height, texture_width), -1, dtype=np.int8)
-
- fill_background(pixels, texture_width, texture_height, (0,0,0,1))
- paint_mask_color(pixels, texture_width, texture_height, mesh_mask, (1,1,1,1))
-
- idx = 0
- for j in range(ny):
- for i in range(nx):
- uv_poly = patch_uv_polygons[idx]; idx += 1
- if uv_poly is None:
- continue
- color = patch_colors[j,i]
- rgba = (color[0], color[1], color[2], 1.0)
- patch_value = 1 if np.mean(color) >= 0.5 else 0
- for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
- fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
- fill_polygon_value(
- patch_state,
- texture_width,
- texture_height,
- pixel_poly,
- patch_value,
- )
-
- complete_checker_gaps(
- pixels, texture_width, texture_height, mesh_mask, patch_state, max_gap_px=gap_fill_px
- )
-
- if uv_centre is not None:
- draw_cross(pixels, texture_width, texture_height,
- uv_centre[0], uv_centre[1],
- cross_length, cross_thickness, cross_color)
-
- image.pixels[:] = pixels
- image.update()
- abs_path = bpy.path.abspath(output_path)
- image.filepath_raw = abs_path
- image.file_format = 'PNG'
- image.save()
- print(f"Checker texture saved to {abs_path}")
- return image
-
-# ----------------------------------------------------------------------
-# Mode 3: Circular Dot Markers
-# ----------------------------------------------------------------------
-def generate_dot_texture(
- obj, cam, scene, depsgraph,
- dot_diameter_pixels=12,
- spacing_pixels=40,
- dot_color=(0,0,0,1), # RGBA 0-1
- texture_width=2048, texture_height=2048,
- output_path="//dot_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
-):
- frame_width, frame_height, res_x, res_y = get_camera_frame(cam, scene)
-
- margin = spacing_pixels // 2
- x_centers = np.arange(margin, res_x, spacing_pixels)
- y_centers = np.arange(margin, res_y, spacing_pixels)
-
- dots = [] # (cx_uv, cy_uv, a_uv, b_uv, angle_uv)
- print(f"Raycasting {len(x_centers)*len(y_centers)} dot centers...")
- for y_pix in y_centers:
- v_ndc = y_pix / res_y
- for x_pix in x_centers:
- u_ndc = x_pix / res_x
- center_loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
- u_ndc, v_ndc, frame_width, frame_height)
- if center_loc is None: continue
- center_uv = get_uv_from_face(obj, face_idx, center_loc)
- if center_uv is None: continue
-
- a_uv, b_uv, angle = estimate_dot_uv_parameters(
- scene, depsgraph, cam, obj,
- u_ndc, v_ndc, center_uv,
- frame_width, frame_height, res_x, res_y,
- texture_width, texture_height, dot_diameter_pixels
- )
- dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle))
-
- print(f"Found {len(dots)} valid dots.")
-
- # Create image
- img_name = "DotTexture"
- if img_name in bpy.data.images:
- bpy.data.images.remove(bpy.data.images[img_name])
- image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
- pixels = list(image.pixels)
-
- # Black background (no mesh)
- fill_background(pixels, texture_width, texture_height, (0,0,0,1))
- # White for all mesh areas
- rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
- # Draw dots
- for (cx_uv, cy_uv, a_uv, b_uv, angle_uv) in dots:
- draw_wrapped_ellipse_uv(
- pixels, texture_width, texture_height,
- cx_uv, cy_uv, a_uv, b_uv, angle_uv, dot_color
- )
-
- if uv_centre is not None:
- draw_cross(pixels, texture_width, texture_height,
- uv_centre[0], uv_centre[1],
- cross_length, cross_thickness, cross_color)
-
- image.pixels[:] = pixels
- image.update()
- abs_path = bpy.path.abspath(output_path)
- image.filepath_raw = abs_path
- image.file_format = 'PNG'
- image.save()
- print(f"Dot texture saved to {abs_path}")
- return image
-
-# ----------------------------------------------------------------------
-# Main
-# ----------------------------------------------------------------------
-def main():
- # ==================== CONFIGURATION ====================
- MODE = "patches" # "patches", "checker", or "dots"
- OBJ_NAME = "FingerMesh0607.001"
- CAM_NAME = "ov9734-114.001"
- TEXTURE_WIDTH = 1640*2
- TEXTURE_HEIGHT = 1600*2
- OUTPUT_NAMES = {
- "patches": "patch_texture.png",
- "checker": "checker_texture.png",
- "dots": "dot_texture.png",
- }
- OUTPUT_PATH = "//" + OUTPUT_NAMES.get(MODE, "texture.png")
-
- # Patch mode
- PATCH_NX = 80; PATCH_NY = 45; COLOR_R = 0.05
- EDGE_SAMPLE_GRID = 5
- FIDUCIAL_NX = 3; FIDUCIAL_NY = 3
- FIDUCIAL_DIAMETER_PIX = 24
- FIDUCIAL_COLOR = (1, 1, 1, 1)
- FIDUCIAL_OUTLINE_COLOR = (0, 0, 0, 1)
- FIDUCIAL_OUTLINE_PX = 2
- FIDUCIAL_EDGE_MARGIN_PIXELS = 12
- # Checker mode
- CHECKER_NX = 40; CHECKER_NY = 40; CHECKER_GAP_FILL_PX = 6
- # Dot mode
- DOT_DIAMETER_PIX = 12; DOT_SPACING_PIX = 40; DOT_COLOR = (0,0,0,1)
- # Cross
- CROSS_LENGTH_PX = 40
- CROSS_THICKNESS_PX = 3
- CROSS_COLOR = (1, 0, 0, 1) # red cross
- # ======================================================
-
- start_time = time.time()
- scene = bpy.context.scene
- depsgraph = bpy.context.evaluated_depsgraph_get()
-
- if OBJ_NAME:
- obj = bpy.data.objects.get(OBJ_NAME)
- else:
- obj = bpy.context.active_object
- if obj is None or obj.type != 'MESH':
- raise ValueError("No valid mesh object found.")
-
- cam = bpy.data.objects.get(CAM_NAME) if CAM_NAME else scene.camera
- if cam is None or cam.type != 'CAMERA':
- raise ValueError("No camera found.")
-
- if not obj.data.uv_layers.active:
- obj.data.uv_layers.new(name="UVMap")
-
- # Compute UV centre
- uv_layer = obj.data.uv_layers.active
- uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
- uv_min = uv_coords.min(axis=0)
- uv_max = uv_coords.max(axis=0)
- uv_centre = (uv_min + uv_max) / 2.0
- print(f"UV centre: ({uv_centre[0]:.4f}, {uv_centre[1]:.4f})")
-
-
- # Compute UV bounding box centre
- me = obj.data
- uv_layer = me.uv_layers.active
- uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
- uv_min = uv_coords.min(axis=0)
- uv_max = uv_coords.max(axis=0)
- uv_centre = (uv_min + uv_max) / 2.0
- print(f"UV centre: {uv_centre[0]:.4f}, {uv_centre[1]:.4f}")
-
- if MODE == "patches":
- generate_patch_texture(
- obj, cam, scene, depsgraph,
- nx=PATCH_NX, ny=PATCH_NY, r=COLOR_R,
- texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
- output_path=OUTPUT_PATH,
- uv_centre=uv_centre,
- cross_length=CROSS_LENGTH_PX,
- cross_thickness=CROSS_THICKNESS_PX,
- cross_color=CROSS_COLOR,
- edge_sample_grid=EDGE_SAMPLE_GRID,
- fiducial_nx=FIDUCIAL_NX,
- fiducial_ny=FIDUCIAL_NY,
- fiducial_diameter_pixels=FIDUCIAL_DIAMETER_PIX,
- fiducial_color=FIDUCIAL_COLOR,
- fiducial_outline_color=FIDUCIAL_OUTLINE_COLOR,
- fiducial_outline_px=FIDUCIAL_OUTLINE_PX,
- fiducial_edge_margin_pixels=FIDUCIAL_EDGE_MARGIN_PIXELS
- )
- elif MODE == "checker":
- generate_checker_texture(
- obj, cam, scene, depsgraph,
- nx=CHECKER_NX, ny=CHECKER_NY,
- texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
- output_path=OUTPUT_PATH,
- uv_centre=uv_centre,
- cross_length=CROSS_LENGTH_PX,
- cross_thickness=CROSS_THICKNESS_PX,
- cross_color=CROSS_COLOR,
- edge_sample_grid=EDGE_SAMPLE_GRID,
- gap_fill_px=CHECKER_GAP_FILL_PX
- )
- elif MODE == "dots":
- generate_dot_texture(
- obj, cam, scene, depsgraph,
- dot_diameter_pixels=DOT_DIAMETER_PIX,
- spacing_pixels=DOT_SPACING_PIX,
- dot_color=DOT_COLOR,
- texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
- output_path=OUTPUT_PATH,
- uv_centre=uv_centre,
- cross_length=CROSS_LENGTH_PX,
- cross_thickness=CROSS_THICKNESS_PX,
- cross_color=CROSS_COLOR
- )
- else:
- raise ValueError(f"Unknown MODE: {MODE!r}. Use 'patches', 'checker', or 'dots'.")
-
- print(f"Completed in {time.time()-start_time:.2f} seconds.")
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/tools/3d_patch_generation_blender/real_image.jpg b/tools/3d_patch_generation_blender/real_image.jpg
deleted file mode 100644
index 6a738a1..0000000
Binary files a/tools/3d_patch_generation_blender/real_image.jpg and /dev/null differ
diff --git a/tools/3d_patch_generation_blender/render_image.png b/tools/3d_patch_generation_blender/render_image.png
deleted file mode 100644
index 0ef5309..0000000
Binary files a/tools/3d_patch_generation_blender/render_image.png and /dev/null differ
diff --git a/tools/3d_patch_generation_blender/unroll_realtime.py b/tools/3d_patch_generation_blender/unroll_realtime.py
deleted file mode 100644
index 66ac098..0000000
--- a/tools/3d_patch_generation_blender/unroll_realtime.py
+++ /dev/null
@@ -1,720 +0,0 @@
-#!/usr/bin/env python3
-"""
-Real-time UV-based perspective correction for the exported finger mesh.
-
-This version replaces the previous cylindrical approximation with a remap built
-from the mesh UV parameterization. The remap is precomputed once and then
-applied to each live frame with cv2.remap().
-"""
-
-import argparse
-import sys
-import time
-
-import cv2
-import numpy as np
-import orisys
-from orisys.configs import load_config
-from orisys.frame_processors import FrameUnwarper
-from orisys.unroll import (
- barycentric_grid,
- build_or_load_domain,
- compute_uv_distortion_metrics,
- face_uvs_from_loop_data,
- load_calib_json,
- load_exported_data,
- recommend_edge_expand,
- resolve_uv_runtime_sizes,
- scale_camera_geometry,
- scaled_map_cache_path,
- uv_triangle_to_image,
-)
-
-
-class StepTimer:
- """Accumulate per-step wall times for rolling runtime averages."""
-
- def __init__(self):
- self._starts = {}
- self.totals = {}
- self.counts = {}
-
- def start(self, name):
- self._starts[name] = time.perf_counter()
-
- def stop(self, name):
- dt = time.perf_counter() - self._starts.pop(name)
- self.totals[name] = self.totals.get(name, 0.0) + dt
- self.counts[name] = self.counts.get(name, 0) + 1
- return dt
-
- def avg_ms(self, name):
- if self.counts.get(name, 0) == 0:
- return 0.0
- return 1000.0 * self.totals[name] / self.counts[name]
-
-
-def benchmark_step(label, func, *args, **kwargs):
- """Run func once and print its wall time."""
- t0 = time.perf_counter()
- result = func(*args, **kwargs)
- ms = (time.perf_counter() - t0) * 1000.0
- print(f"[benchmark] {label}: {ms:.1f} ms")
- return result
-
-
-def print_runtime_benchmark(timer):
- """Format rolling average timings for the live loop."""
- parts = [
- f"capture={timer.avg_ms('capture'):.1f}ms",
- f"uv_prep={timer.avg_ms('uv_prepare'):.1f}ms",
- f"uv_remap={timer.avg_ms('uv_remap'):.1f}ms",
- f"get_img={timer.avg_ms('get_img'):.1f}ms",
- f"flow={timer.avg_ms('flow'):.1f}ms",
- f"hhd={timer.avg_ms('hhd'):.1f}ms",
- f"contact={timer.avg_ms('contact'):.1f}ms",
- f"viz={timer.avg_ms('viz'):.1f}ms",
- ]
- return "bench[" + ", ".join(parts) + "]"
-
-
-def resize_to_height(image, target_height):
- """Resize an image to a target height while preserving aspect ratio."""
- if image.shape[0] == target_height:
- return image
- scale = target_height / float(image.shape[0])
- target_width = max(1, int(round(image.shape[1] * scale)))
- return cv2.resize(image, (target_width, target_height), interpolation=cv2.INTER_AREA)
-
-
-def resize_to_fit(image, target_width, target_height):
- """Resize with aspect ratio preserved and pad to exact tile size."""
- h, w = image.shape[:2]
- if h <= 0 or w <= 0:
- return np.zeros((target_height, target_width, 3), dtype=np.uint8)
-
- scale = min(target_width / float(w), target_height / float(h))
- new_w = max(1, int(round(w * scale)))
- new_h = max(1, int(round(h * scale)))
- resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
-
- if resized.ndim == 2:
- resized = cv2.cvtColor(resized, cv2.COLOR_GRAY2BGR)
- elif resized.ndim == 3 and resized.shape[2] == 1:
- resized = cv2.cvtColor(resized[:, :, 0], cv2.COLOR_GRAY2BGR)
-
- canvas = np.zeros((target_height, target_width, 3), dtype=np.uint8)
- y0 = (target_height - new_h) // 2
- x0 = (target_width - new_w) // 2
- canvas[y0:y0 + new_h, x0:x0 + new_w] = resized
- return canvas
-
-
-def add_panel_title(image, title):
- """Overlay a simple title onto a display tile."""
- out = image.copy()
- cv2.rectangle(out, (0, 0), (out.shape[1], 36), (0, 0, 0), thickness=-1)
- cv2.putText(out, title, (12, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA)
- return out
-
-
-def apply_display_mask(image, valid_mask, border_value=0):
- """Zero invalid UV pixels in visualization panels."""
- if image is None or valid_mask is None:
- return image
- out = image.copy()
- mask = valid_mask.astype(np.uint8)
- if mask.shape != out.shape[:2]:
- mask = cv2.resize(mask, (out.shape[1], out.shape[0]), interpolation=cv2.INTER_NEAREST)
- invalid = ~mask.astype(bool)
- out[invalid] = border_value
- return out
-
-
-def contact_inside_mask(centroid, valid_mask):
- if centroid is None or valid_mask is None:
- return None
- if len(centroid) < 2:
- return None
- cx, cy = int(centroid[0]), int(centroid[1])
- if cy < 0 or cy >= valid_mask.shape[0] or cx < 0 or cx >= valid_mask.shape[1]:
- return False
- return bool(valid_mask[cy, cx])
-
-
-def overlay_runtime_status(image, sensor, centroid=None):
- """Annotate a panel with mask-aware runtime diagnostics."""
- out = image.copy()
- stats = getattr(sensor, "mask_stats", {}) or {}
- coverage = 100.0 * float(stats.get("coverage", 0.0))
- flow_valid = float(stats.get("flow_mean_valid", 0.0))
- flow_invalid = float(stats.get("flow_mean_invalid", 0.0))
- centroid_ok = contact_inside_mask(centroid, getattr(sensor, "valid_mask", None))
- centroid_label = "n/a" if centroid_ok is None else ("inside" if centroid_ok else "outside")
- lines = [
- f"mask {coverage:.1f}%",
- f"flow valid {flow_valid:.3f}",
- f"flow invalid {flow_invalid:.3f}",
- f"contact {centroid_label}",
- ]
- y = 54
- for line in lines:
- cv2.putText(out, line, (12, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 2, cv2.LINE_AA)
- y += 24
- return out
-
-
-def make_tiled_dashboard(panels, tile_width=640, tile_height=360):
- """Arrange four panels into a 2x2 dashboard."""
- tiles = [
- add_panel_title(resize_to_fit(image, tile_width, tile_height), title)
- for title, image in panels
- ]
- top_row = np.hstack(tiles[:2])
- bottom_row = np.hstack(tiles[2:4])
- return np.vstack([top_row, bottom_row])
-
-
-def make_uv_debug_image(data, out_width, out_height, flip_v=True, edge_band=0.12, edge_expand=1.0):
- """Create a UV-domain reference image to verify orientation of the remap."""
- face_uvs = face_uvs_from_loop_data(data["faces"], data["uv_data"], data["loop_to_vertex"])
- uv_img = np.zeros((out_height, out_width, 3), dtype=np.uint8)
- label_color = (255, 255, 255)
-
- for uv_tri in face_uvs:
- tri = uv_triangle_to_image(
- uv_tri,
- out_width,
- out_height,
- flip_v=flip_v,
- edge_band=edge_band,
- edge_expand=edge_expand,
- )
- pts = np.round(tri).astype(np.int32)
-
- mask = np.zeros((out_height, out_width), dtype=np.uint8)
- cv2.fillConvexPoly(mask, pts, 255)
-
- x0 = max(0, int(np.floor(np.min(tri[:, 0]))))
- x1 = min(out_width - 1, int(np.ceil(np.max(tri[:, 0]))))
- y0 = max(0, int(np.floor(np.min(tri[:, 1]))))
- y1 = min(out_height - 1, int(np.ceil(np.max(tri[:, 1]))))
- if x1 < x0 or y1 < y0:
- continue
-
- yy, xx = np.mgrid[y0:y1 + 1, x0:x1 + 1]
- inside = mask[y0:y1 + 1, x0:x1 + 1] > 0
- if not np.any(inside):
- continue
-
- bary = barycentric_grid(xx + 0.5, yy + 0.5, tri)
- if bary is None:
- continue
- w0, w1, w2 = bary
- valid = inside & (w0 >= -1e-6) & (w1 >= -1e-6) & (w2 >= -1e-6)
- if not np.any(valid):
- continue
-
- uv = (
- w0[valid, None] * uv_tri[0]
- + w1[valid, None] * uv_tri[1]
- + w2[valid, None] * uv_tri[2]
- )
- colors = np.zeros((uv.shape[0], 3), dtype=np.uint8)
- colors[:, 0] = np.clip(uv[:, 0] * 255.0, 0, 255).astype(np.uint8)
- colors[:, 1] = np.clip(uv[:, 1] * 255.0, 0, 255).astype(np.uint8)
- colors[:, 2] = np.clip((1.0 - 0.5 * (uv[:, 0] + uv[:, 1])) * 255.0, 0, 255).astype(np.uint8)
- uv_img[yy[valid], xx[valid]] = colors
-
- cv2.polylines(uv_img, [pts.reshape(-1, 1, 2)], isClosed=True, color=(255, 255, 255), thickness=1)
-
- cv2.arrowedLine(uv_img, (20, out_height - 20), (min(out_width - 20, 140), out_height - 20), (0, 0, 255), 2, tipLength=0.15)
- cv2.arrowedLine(uv_img, (20, out_height - 20), (20, max(20, out_height - 140)), (0, 255, 0), 2, tipLength=0.15)
- cv2.putText(uv_img, "U", (min(out_width - 30, 145), out_height - 28), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2, cv2.LINE_AA)
- cv2.putText(uv_img, "V", (28, max(30, out_height - 145)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2, cv2.LINE_AA)
- cv2.putText(uv_img, "UV Map", (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.9, label_color, 2, cv2.LINE_AA)
- if edge_expand > 1.0:
- cv2.putText(uv_img, f"Edge expand {edge_expand:.2f}x", (20, 64), cv2.FONT_HERSHEY_SIMPLEX, 0.55, label_color, 2, cv2.LINE_AA)
- return uv_img
-
-
-def make_uv_distortion_image(data, distortion, out_width, out_height, flip_v=True, edge_band=0.12, edge_expand=1.0):
- """Render UV distortion heatmap; red means more 3D surface per UV area."""
- face_uvs = face_uvs_from_loop_data(data["faces"], data["uv_data"], data["loop_to_vertex"])
- uv_img = np.zeros((out_height, out_width, 3), dtype=np.uint8)
- rel = distortion["relative_scale"]
- edge_mask = distortion["edge_mask"]
- valid_rel = np.isfinite(rel) & (rel > 0)
- if not np.any(valid_rel):
- return uv_img
-
- log_rel = np.log2(rel[valid_rel])
- clip = max(0.25, float(np.percentile(np.abs(log_rel), 95)))
-
- for idx, uv_tri in enumerate(face_uvs):
- tri = uv_triangle_to_image(
- uv_tri,
- out_width,
- out_height,
- flip_v=flip_v,
- edge_band=edge_band,
- edge_expand=edge_expand,
- )
- pts = np.round(tri).astype(np.int32)
-
- if not np.isfinite(rel[idx]) or rel[idx] <= 0:
- color = (64, 64, 64)
- else:
- value = np.clip((np.log2(rel[idx]) + clip) / (2.0 * clip), 0.0, 1.0)
- color_idx = np.uint8(round(value * 255.0))
- color = tuple(int(c) for c in cv2.applyColorMap(np.array([[color_idx]], dtype=np.uint8), cv2.COLORMAP_JET)[0, 0])
-
- cv2.fillConvexPoly(uv_img, pts, color)
- edge_color = (255, 255, 255) if edge_mask[idx] else (180, 180, 180)
- edge_thickness = 2 if edge_mask[idx] else 1
- cv2.polylines(uv_img, [pts.reshape(-1, 1, 2)], isClosed=True, color=edge_color, thickness=edge_thickness)
-
- cv2.putText(uv_img, "UV Distortion", (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA)
- cv2.putText(uv_img, "Blue: denser UV Red: sparser UV", (20, 64), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (220, 220, 220), 2, cv2.LINE_AA)
- cv2.putText(uv_img, "White outlines: edge-band faces", (20, 92), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (220, 220, 220), 2, cv2.LINE_AA)
- middle_stats = distortion["middle_stats"]
- edge_stats = distortion["edge_stats"]
- cv2.putText(
- uv_img,
- f"middle p05/p95: {middle_stats['relative_scale_p05']:.2f}x / {middle_stats['relative_scale_p95']:.2f}x",
- (20, 120),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.55,
- (220, 220, 220),
- 2,
- cv2.LINE_AA,
- )
- cv2.putText(
- uv_img,
- f"edge p05/p95: {edge_stats['relative_scale_p05']:.2f}x / {edge_stats['relative_scale_p95']:.2f}x",
- (20, 148),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.55,
- (220, 220, 220),
- 2,
- cv2.LINE_AA,
- )
- return uv_img
-
-
-def open_source(video_path, camera_id):
- return cv2.VideoCapture(video_path) if video_path else cv2.VideoCapture(camera_id)
-
-
-PERF_TUNING_EPILOG = """
-Performance tuning (config JSON + CLI):
- camera.resolution_stitch [280, 300] UV output + SDK flow/HHD grid
- camera.source_scale 0.4 shrink source before uv_prep/remap
- tracker.dis_preset medium -> fast faster DIS optical flow
- tracker.hhd_downsample 5 -> 4 smaller HHD Poisson grid
- contact.denoise_sigma 12 -> 6 or 0 faster FoE contact step
- --source_scale 0.4 override config source scale
- --masked_poisson_solver sparse vs capfft benchmark HHD solve time
- --no-viz skip dashboard ~20 ms/frame savings
-
-Recommended dd02-ov fast profile:
- resolution_stitch [280, 300], source_scale 0.4, hhd_downsample 5
- Delete or rename stale unroll_map_uv.npz after changing output scale.
-"""
-
-
-def run_deformation_timed(sensor, timer, check_motion=True, threshold=0, tail_frames=10):
- """Run flow + gated HHD with separate benchmark buckets."""
- timer.start("flow")
- sensor.compute_flow()
- timer.stop("flow")
-
- timer.start("hhd")
- run_hhd = True
- if check_motion:
- is_motion = sensor.detect_motion(threshold=threshold)
- try:
- tail_frames = max(0, int(tail_frames))
- except (TypeError, ValueError):
- tail_frames = 0
- if is_motion:
- sensor.tail_frames_count = 0
- elif sensor.tail_frames_count < tail_frames:
- sensor.tail_frames_count += 1
- else:
- run_hhd = False
- sensor.tail_frames_count = tail_frames
- if run_hhd:
- sensor.compute_hhd()
- timer.stop("hhd")
-
-
-def configure_masked_poisson(sensor, args):
- """Apply masked-HHD solver settings to the externally fed SDK sensor."""
- sensor.masked_poisson_solver = args.masked_poisson_solver
- sensor.masked_poisson_max_boundary = args.masked_poisson_max_boundary
- sensor.masked_poisson_boundary_stride = args.masked_poisson_boundary_stride
- sensor.masked_poisson_max_iters = args.masked_poisson_max_iters
- sensor.masked_poisson_tol = args.masked_poisson_tol
- sensor.masked_poisson_reg = args.masked_poisson_reg
-
-
-def resolve_runtime_geometry(args):
- """Apply config defaults and optional source-scale to UV/camera geometry."""
- sdk_config = load_config(args.sdk_config)
- camera_config = sdk_config.get("camera", {})
- out_width, out_height, source_scale = resolve_uv_runtime_sizes(
- camera_config,
- out_width=args.out_width,
- out_height=args.out_height,
- source_scale=args.source_scale,
- )
- stitch = camera_config.get("resolution_stitch")
- if stitch and len(stitch) >= 2 and (int(stitch[0]) != out_width or int(stitch[1]) != out_height):
- print(
- "Warning: UV output "
- f"{out_width}x{out_height} differs from resolution_stitch "
- f"{int(stitch[0])}x{int(stitch[1])} in {args.sdk_config}; "
- "Sensor.get_img will resize again."
- )
-
- args.out_width = out_width
- args.out_height = out_height
- args.source_scale = source_scale
- args.map_cache = scaled_map_cache_path(args.map_cache, source_scale)
- return sdk_config, camera_config
-
-
-def main():
- parser = argparse.ArgumentParser(
- description="Real-time UV-based finger unrolling",
- epilog=PERF_TUNING_EPILOG,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- )
- parser.add_argument("--mesh", required=True, help="Exported mesh.npz path")
- parser.add_argument("--K", dest="intrinsics_path", required=True, help="Exported camera_intrinsics.npz path")
- parser.add_argument("--pose", required=True, help="Exported world_to_cam.npy path")
- parser.add_argument("--calib", default=None, help="OpenCV calibration JSON for the live camera")
- # With a 12% UV edge band on each side, only about 76% of each axis is the
- # lower-distortion middle region. Use a slightly larger full remap so the
- # useful center keeps roughly the same sampling density as the prior 506x550.
- parser.add_argument("--out_width", type=int, default=None, help="UV flatten width (default: camera.resolution_stitch in sdk config)")
- parser.add_argument("--out_height", type=int, default=None, help="UV flatten height (default: camera.resolution_stitch in sdk config)")
- parser.add_argument(
- "--source_scale",
- type=float,
- default=None,
- help="Resize live source to this fraction before uv_prep/uv_remap; scales K accordingly (default: camera.source_scale or work4[3] in sdk config, else 1.0)",
- )
- parser.add_argument("--camera_id", type=int, default=0)
- parser.add_argument("--video", help="Video file path (overrides camera)")
- parser.add_argument("--map_cache", default="unroll_map_uv.npz")
- parser.add_argument("--no_flip_v", action="store_true", help="Keep UV V increasing downward")
- parser.add_argument("--no_flip_source_vertical", action="store_true", help="Do not vertically flip the live source before UV remap")
- parser.add_argument("--edge_band", type=float, default=0.12, help="Normalized UV band near the boundary treated as high-distortion edge")
- parser.add_argument("--edge_expand", type=float, default=0.0, help="Extra UV allocation for edge band; 0 chooses automatically from distortion stats")
- parser.add_argument("--sdk_config", default="./config/dd02-ov.json", help="Orisys SDK config path/name")
- parser.add_argument("--sdk_source", default=None, help="Dummy source used to initialize Sensor; defaults to --video or --camera_id")
- parser.add_argument("--masked_poisson_solver", choices=["sparse", "capfft"], default="sparse", help="Masked HHD Poisson solver used inside the SDK")
- parser.add_argument("--masked_poisson_max_boundary", type=int, default=96, help="Maximum number of UV-boundary samples used by the capfft solver")
- parser.add_argument("--masked_poisson_boundary_stride", type=int, default=1, help="Subsample stride applied before capfft boundary sampling")
- parser.add_argument("--masked_poisson_max_iters", type=int, default=3, help="Maximum capfft correction iterations per Poisson solve")
- parser.add_argument("--masked_poisson_tol", type=float, default=1e-4, help="Residual tolerance used by the capfft correction loop")
- parser.add_argument("--masked_poisson_reg", type=float, default=1e-8, help="Diagonal regularization applied to the capacitance matrix")
- parser.add_argument("--verbose", action="store_true", help="Enable verbose SDK logs")
- parser.add_argument(
- "--no-viz",
- action="store_true",
- help="Skip OpenCV dashboard; print forces and benchmark timings only",
- )
- args = parser.parse_args()
- _, camera_config = resolve_runtime_geometry(args)
-
- print("[benchmark] Startup timing:")
- data = benchmark_step("load_exported_data", load_exported_data, args.mesh, args.intrinsics_path, args.pose)
- flip_v = not args.no_flip_v
- flip_source_vertical = not args.no_flip_source_vertical
- distortion = benchmark_step(
- "compute_uv_distortion_metrics",
- compute_uv_distortion_metrics,
- data,
- edge_band=args.edge_band,
- )
- distortion_stats = distortion["stats"]
- middle_stats = distortion["middle_stats"]
- edge_stats = distortion["edge_stats"]
- edge_expand = args.edge_expand if args.edge_expand > 1.0 else recommend_edge_expand(distortion)
- print(
- "UV distortion stats: "
- f"median mm/uv={distortion_stats['median_mm_per_uv']:.3f}, "
- f"relative scale p05/p95={distortion_stats['relative_scale_p05']:.2f}x/"
- f"{distortion_stats['relative_scale_p95']:.2f}x, "
- f"anisotropy p50/p95={distortion_stats['anisotropy_p50']:.2f}x/"
- f"{distortion_stats['anisotropy_p95']:.2f}x"
- )
- print(f"Using UV edge warp: band={args.edge_band:.2f}, expand={edge_expand:.2f}x")
- print(
- "UV middle-region stats: "
- f"faces={middle_stats['face_count']}, "
- f"relative scale p05/p95={middle_stats['relative_scale_p05']:.2f}x/"
- f"{middle_stats['relative_scale_p95']:.2f}x, "
- f"anisotropy p50/p95={middle_stats['anisotropy_p50']:.2f}x/"
- f"{middle_stats['anisotropy_p95']:.2f}x"
- )
- print(
- "UV edge-band stats: "
- f"faces={edge_stats['face_count']}, "
- f"relative scale p05/p95={edge_stats['relative_scale_p05']:.2f}x/"
- f"{edge_stats['relative_scale_p95']:.2f}x, "
- f"anisotropy p50/p95={edge_stats['anisotropy_p50']:.2f}x/"
- f"{edge_stats['anisotropy_p95']:.2f}x"
- )
-
- if args.calib:
- source_K, dist_coeffs, img_size = benchmark_step("load_calib_json", load_calib_json, args.calib)
- native_width, native_height = img_size
- print(f"Calibration loaded. Live image size: {(native_width, native_height)}")
- else:
- source_K = data["K"]
- dist_coeffs = None
- native_width = data["width"]
- native_height = data["height"]
- print("No calibration file provided; using exported intrinsics for projection.")
-
- source_K, source_width, source_height = scale_camera_geometry(
- source_K,
- native_width,
- native_height,
- args.source_scale,
- )
- print(
- "UV runtime geometry: "
- f"native={native_width}x{native_height}, "
- f"source={source_width}x{source_height} (scale={args.source_scale:g}), "
- f"output={args.out_width}x{args.out_height}, "
- f"map_cache={args.map_cache}"
- )
-
- domain = benchmark_step(
- "build_or_load_domain",
- build_or_load_domain,
- args.mesh,
- args.intrinsics_path,
- args.pose,
- args.map_cache,
- source_K,
- source_width,
- source_height,
- args.out_width,
- args.out_height,
- flip_v=flip_v,
- edge_band=args.edge_band,
- edge_expand=edge_expand,
- include_surface=True,
- source_scale=args.source_scale,
- )
- remap = domain.remap
- remap.ensure_fast_maps()
- edge_expand = float(remap.metadata.get("edge_expand", edge_expand))
-
- uv_debug = benchmark_step(
- "make_uv_debug_image",
- make_uv_debug_image,
- data,
- out_width=args.out_width,
- out_height=args.out_height,
- flip_v=flip_v,
- edge_band=args.edge_band,
- edge_expand=edge_expand,
- )
- uv_distortion = benchmark_step(
- "make_uv_distortion_image",
- make_uv_distortion_image,
- data,
- distortion,
- out_width=args.out_width,
- out_height=args.out_height,
- flip_v=flip_v,
- edge_band=args.edge_band,
- edge_expand=edge_expand,
- )
- uv_reference = benchmark_step(
- "uv_reference_compose",
- lambda: np.hstack(
- [
- resize_to_height(uv_debug, args.out_height),
- resize_to_height(uv_distortion, args.out_height),
- ]
- ),
- )
-
- cap = open_source(args.video, args.camera_id)
- if not cap.isOpened():
- print("Cannot open camera/video source")
- sys.exit(1)
-
- cap.set(cv2.CAP_PROP_FRAME_WIDTH, native_width)
- cap.set(cv2.CAP_PROP_FRAME_HEIGHT, native_height)
-
- sensor = benchmark_step(
- "sensor_init",
- lambda: orisys.Sensor(
- "external",
- config_name=args.sdk_config,
- verbose=args.verbose,
- mirror=False,
- ),
- )
- configure_masked_poisson(sensor, args)
- print(
- "Masked HHD solver: "
- f"{sensor.masked_poisson_solver} "
- f"(max_boundary={sensor.masked_poisson_max_boundary}, "
- f"stride={sensor.masked_poisson_boundary_stride}, "
- f"iters={sensor.masked_poisson_max_iters}, "
- f"tol={sensor.masked_poisson_tol:.1e})"
- )
-
- frame_processor = FrameUnwarper(
- domain.remap,
- camera_matrix=source_K if dist_coeffs is not None else None,
- dist_coeffs=dist_coeffs,
- source_width=source_width,
- source_height=source_height,
- flip_source_vertical=flip_source_vertical,
- border_value=(0, 0, 0),
- )
-
- ok, warmup_frame = cap.read()
- if ok:
- warmup_prepared = frame_processor.prepare_source(warmup_frame)
- warmup_unrolled = frame_processor.flatten_prepared(warmup_prepared)
- sensor.get_img(
- frame=warmup_unrolled,
- already_processed=True,
- valid_mask=frame_processor.valid_mask,
- )
- benchmark_step("hhd_warmup", sensor.warmup_deformation)
- else:
- print("[benchmark] hhd_warmup: skipped (no frame from source)")
-
- if args.no_viz:
- print("Running with --no-viz (dashboard disabled).")
- else:
- print("Press 'q' to quit.")
- runtime_timer = StepTimer()
- try:
- while True:
- runtime_timer.start("capture")
- ok, frame = cap.read()
- runtime_timer.stop("capture")
- if not ok:
- break
-
- runtime_timer.start("uv_prepare")
- frame_source = frame_processor.prepare_source(frame)
- runtime_timer.stop("uv_prepare")
-
- runtime_timer.start("uv_remap")
- unrolled = frame_processor.flatten_prepared(frame_source)
- runtime_timer.stop("uv_remap")
-
- runtime_timer.start("get_img")
- sensor.get_img(
- frame=unrolled,
- already_processed=True,
- valid_mask=frame_processor.valid_mask,
- )
- runtime_timer.stop("get_img")
-
- run_deformation_timed(sensor, runtime_timer, check_motion=True, threshold=0)
-
- runtime_timer.start("contact")
- sensor.compute_contact()
- runtime_timer.stop("contact")
-
- fps = sensor._fps
- fnormal = sensor.fnormal
- fshearx = sensor.fshearx
- fsheary = sensor.fsheary
- contact_ok = contact_inside_mask(sensor.centroid, sensor.valid_mask)
-
- if not args.no_viz:
- runtime_timer.start("viz")
- fps, flow, vnormal, vshear, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = sensor.read_info(
- sensor.info.FPS,
- sensor.info.VRAW,
- sensor.info.VNORMAL,
- sensor.info.VSHEAR,
- sensor.info.IMG,
- sensor.info.CONTOUR,
- sensor.info.CENTROID,
- sensor.info.DEPTH,
- sensor.info.FNORMAL,
- sensor.info.FSHEARX,
- sensor.info.FSHEARY,
- )
-
- arrows = orisys.util.draw_arrows(
- img,
- flow,
- threshold=0.4,
- grid_spacing=10,
- arrow_scale=1.0,
- )
- contact_img = orisys.util.draw_contact(img, contour, centroid)
-
- depth_norm = depth_map
- if depth_norm.size > 0 and depth_norm.max() > depth_norm.min():
- depth_u8 = ((depth_norm - depth_norm.min()) / (depth_norm.max() - depth_norm.min()) * 255).astype(np.uint8)
- else:
- depth_u8 = np.zeros_like(depth_norm, dtype=np.uint8)
- depth_colored = cv2.applyColorMap(depth_u8, cv2.COLORMAP_JET)
-
- display_mask = sensor.valid_mask
- arrows = apply_display_mask(arrows, display_mask, border_value=(0, 0, 0))
- contact_img = apply_display_mask(contact_img, display_mask, border_value=(0, 0, 0))
- depth_colored = apply_display_mask(depth_colored, display_mask, border_value=(0, 0, 0))
- arrows = overlay_runtime_status(arrows, sensor, centroid=centroid)
- contact_ok = contact_inside_mask(centroid, display_mask)
-
- dashboard = make_tiled_dashboard(
- [
- ("Original", frame_source),
- ("UV Map | Distortion", uv_reference),
- ("UV Unrolled", unrolled),
- ("Flow", arrows),
- ],
- tile_width=640,
- tile_height=360,
- )
- cv2.imshow("UV Unroll Dashboard", dashboard)
- runtime_timer.stop("viz")
-
- print(
- f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
- f"切向力Y={fsheary:.4f}, 展开图尺寸={unrolled.shape[:2]}, "
- f"solver={sensor.masked_poisson_solver}, "
- f"mask={sensor.mask_stats['coverage']:.3f}, "
- f"flow(valid/invalid)=({sensor.mask_stats['flow_mean_valid']:.3f}/"
- f"{sensor.mask_stats['flow_mean_invalid']:.3f}), "
- f"contact_in_mask={contact_ok}, "
- f"{print_runtime_benchmark(runtime_timer)}",
- end="\r",
- )
-
- key = cv2.waitKey(1) & 0xFF
- if key == ord("q"):
- print("\n正在退出程序...")
- break
- if key == ord("r"):
- sensor.reset()
- print("\n追踪器已重置。")
- finally:
- cap.release()
- sensor.disconnect()
- cv2.destroyAllWindows()
-
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/tools/3d_patch_generation_blender/unrolled.png b/tools/3d_patch_generation_blender/unrolled.png
deleted file mode 100644
index 1de9fcf..0000000
Binary files a/tools/3d_patch_generation_blender/unrolled.png and /dev/null differ
diff --git a/tools/dd02_3dviewer/exported_data/camera_intrinsics.npz b/tools/dd02_3dviewer/exported_data/camera_intrinsics.npz
deleted file mode 100644
index 56b7885..0000000
Binary files a/tools/dd02_3dviewer/exported_data/camera_intrinsics.npz and /dev/null differ
diff --git a/tools/dd02_3dviewer/exported_data/flatten_remap.npz b/tools/dd02_3dviewer/exported_data/flatten_remap.npz
deleted file mode 100644
index 02ca287..0000000
Binary files a/tools/dd02_3dviewer/exported_data/flatten_remap.npz and /dev/null differ
diff --git a/tools/dd02_3dviewer/exported_data/mesh.npz b/tools/dd02_3dviewer/exported_data/mesh.npz
deleted file mode 100644
index 0b95784..0000000
Binary files a/tools/dd02_3dviewer/exported_data/mesh.npz and /dev/null differ
diff --git a/tools/dd02_3dviewer/exported_data/pixel_to_xyz.npz b/tools/dd02_3dviewer/exported_data/pixel_to_xyz.npz
deleted file mode 100644
index 7c83a7b..0000000
Binary files a/tools/dd02_3dviewer/exported_data/pixel_to_xyz.npz and /dev/null differ
diff --git a/tools/dd02_3dviewer/exported_data/world_to_cam.npy b/tools/dd02_3dviewer/exported_data/world_to_cam.npy
deleted file mode 100644
index e59cfe7..0000000
Binary files a/tools/dd02_3dviewer/exported_data/world_to_cam.npy and /dev/null differ
diff --git a/tools/dd02_3dviewer/mapto3d.py b/tools/dd02_3dviewer/mapto3d.py
deleted file mode 100644
index 49ffb05..0000000
--- a/tools/dd02_3dviewer/mapto3d.py
+++ /dev/null
@@ -1,188 +0,0 @@
-import numpy as np
-import cv2
-import os
-
-def load_exported_data(data_dir):
- """Load all exported files."""
- # Mesh
- mesh = np.load(os.path.join(data_dir, "mesh.npz"))
- vertices = mesh['vertices']
- faces = mesh['faces']
- uv_data = mesh['uv_data']
- loop_to_vertex = mesh['loop_to_vertex']
-
- # Camera intrinsics
- cam_data = np.load(os.path.join(data_dir, "camera_intrinsics.npz"))
- K = cam_data['K']
- width = int(cam_data['width'])
- height = int(cam_data['height'])
-
- # Camera pose (world-to-camera)
- world_to_cam = np.load(os.path.join(data_dir, "world_to_cam.npy"))
-
- # Precomputed pixel-to-3D map (optional)
- xyz_map_path = os.path.join(data_dir, "pixel_to_xyz.npz")
- if os.path.exists(xyz_map_path):
- xyz_map = np.load(xyz_map_path)['xyz_map']
- else:
- xyz_map = None
-
- return {
- 'vertices': vertices,
- 'faces': faces,
- 'uv_data': uv_data,
- 'loop_to_vertex': loop_to_vertex,
- 'K': K,
- 'width': width,
- 'height': height,
- 'world_to_cam': world_to_cam,
- 'xyz_map': xyz_map
- }
-
-def ray_intersect_triangle(ray_origin, ray_dir, v0, v1, v2):
- """Möller–Trumbore intersection algorithm. Returns t (distance) or None."""
- epsilon = 1e-8
- edge1 = v1 - v0
- edge2 = v2 - v0
- h = np.cross(ray_dir, edge2)
- a = np.dot(edge1, h)
- if abs(a) < epsilon:
- return None
- f = 1.0 / a
- s = ray_origin - v0
- u = f * np.dot(s, h)
- if u < 0.0 or u > 1.0:
- return None
- q = np.cross(s, edge1)
- v = f * np.dot(ray_dir, q)
- if v < 0.0 or u + v > 1.0:
- return None
- t = f * np.dot(edge2, q)
- if t > epsilon:
- return t
- return None
-
-def pixel_to_3d_ray(u, v, K, world_to_cam):
- """
- Given pixel coordinates (u,v) and camera intrinsics K,
- return a ray in world coordinates: origin and direction.
- """
- # Invert K to get normalized image coordinates
- fx = K[0,0]; fy = K[1,1]; cx = K[0,2]; cy = K[1,2]
- x_norm = (u - cx) / fx
- y_norm = (v - cy) / fy
-
- # Ray in camera space: (x_norm, y_norm, 1.0)
- ray_cam = np.array([x_norm, y_norm, 1.0], dtype=np.float64)
-
- # Transform to world
- cam_to_world = np.linalg.inv(world_to_cam)
- R = cam_to_world[:3, :3]
- t = cam_to_world[:3, 3]
- ray_world_dir = R @ ray_cam
- ray_world_dir /= np.linalg.norm(ray_world_dir)
- ray_world_origin = t
-
- return ray_world_origin, ray_world_dir
-
-def map_image_to_3d(image_path, data, use_precomputed=True, flip_vertical=True):
- """
- Map each pixel of the image to a 3D world point.
- If use_precomputed and xyz_map exists, return that directly.
- If `flip_vertical` is True, flip the loaded image top-to-bottom so its
- row order matches the exported Blender camera/pixel mapping.
- Otherwise, perform raycasting on the mesh.
- Returns:
- points: (H, W, 3) array of 3D points (NaN for misses).
- colors: (H, W, 3) array of RGB colors from image.
- """
- img = cv2.imread(image_path)
- if img is None:
- raise FileNotFoundError(f"Could not read image: {image_path}")
- img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # to RGB
- if flip_vertical:
- img = np.flipud(img).copy()
- h, w = img.shape[:2]
-
- if use_precomputed and data['xyz_map'] is not None:
- # Use precomputed map (must match resolution)
- if h != data['height'] or w != data['width']:
- raise ValueError("Image resolution does not match precomputed map.")
- points = data['xyz_map'].copy()
- colors = img.astype(np.float32) / 255.0
- return points, colors
-
- # Otherwise, perform raycasting on the mesh
- vertices = data['vertices']
- faces = data['faces']
- K = data['K']
- world_to_cam = data['world_to_cam']
-
- points = np.full((h, w, 3), np.nan, dtype=np.float32)
- colors = img.astype(np.float32) / 255.0
-
- # For speed, we can use a bounding volume hierarchy (BVH).
- # Here we implement a simple brute-force over all faces (slow for large meshes).
- # In practice, precomputing the xyz_map is strongly recommended.
-
- for y in range(h):
- for x in range(w):
- origin, direction = pixel_to_3d_ray(x, y, K, world_to_cam)
- best_t = np.inf
- best_hit = None
- for face in faces:
- v0 = vertices[face[0]]
- v1 = vertices[face[1]]
- v2 = vertices[face[2]]
- t = ray_intersect_triangle(origin, direction, v0, v1, v2)
- if t is not None and t < best_t:
- best_t = t
- best_hit = origin + direction * t
- if best_hit is not None:
- points[y, x] = best_hit
-
- return points, colors
-
-def save_colored_pointcloud(points, colors, output_path):
- """Save as PLY file (colored point cloud)."""
- # Flatten valid points
- mask = ~np.isnan(points[:,:,0])
- valid_points = points[mask].reshape(-1, 3)
- valid_colors = (colors[mask].reshape(-1, 3) * 255).astype(np.uint8)
-
- with open(output_path, 'w') as f:
- f.write("ply\n")
- f.write("format ascii 1.0\n")
- f.write(f"element vertex {len(valid_points)}\n")
- f.write("property float x\n")
- f.write("property float y\n")
- f.write("property float z\n")
- f.write("property uchar red\n")
- f.write("property uchar green\n")
- f.write("property uchar blue\n")
- f.write("end_header\n")
- for pt, col in zip(valid_points, valid_colors):
- f.write(f"{pt[0]} {pt[1]} {pt[2]} {col[0]} {col[1]} {col[2]}\n")
- print(f"Saved point cloud to {output_path}")
-
-if __name__ == "__main__":
- data_dir = "./exported_data" # Path to exported files
- image_path = "captured_image_1.jpg" # Input camera image
- output_ply = "mapped_points.ply"
-
- data = load_exported_data(data_dir)
- points, colors = map_image_to_3d(
- image_path,
- data,
- use_precomputed=True,
- flip_vertical=True,
- )
- save_colored_pointcloud(points, colors, output_ply)
-
- # Optional: Visualize using Open3D
- import open3d as o3d
- pcd = o3d.geometry.PointCloud()
- mask = ~np.isnan(points[:,:,0])
- pcd.points = o3d.utility.Vector3dVector(points[mask].reshape(-1,3))
- pcd.colors = o3d.utility.Vector3dVector(colors[mask].reshape(-1,3))
- o3d.visualization.draw_geometries([pcd])
\ No newline at end of file
diff --git a/tools/dd02_3dviewer/sdk_mapto3d.py b/tools/dd02_3dviewer/sdk_mapto3d.py
deleted file mode 100644
index 31cd6d7..0000000
--- a/tools/dd02_3dviewer/sdk_mapto3d.py
+++ /dev/null
@@ -1,629 +0,0 @@
-import argparse
-import os
-import sys
-import time
-
-import cv2
-import numpy as np
-
-# UV-unroll mesh viewer (grey-white surface, UV-space field mapping):
-# from examples.viz.mesh_viewer import UVMeshViewer, build_uv_surface_mesh
-# # or: from orisys.finger import UVMeshViewer, build_uv_surface_mesh
-# See examples/finger_pipeline_3dview.py
-
-
-SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
-REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
-SRC_DIR = os.path.join(REPO_ROOT, "src")
-if SRC_DIR not in sys.path:
- sys.path.insert(0, SRC_DIR)
-
-import orisys
-
-
-def load_exported_data(data_dir):
- mesh = np.load(os.path.join(data_dir, "mesh.npz"))
- cam_data = np.load(os.path.join(data_dir, "camera_intrinsics.npz"))
- xyz_map_path = os.path.join(data_dir, "pixel_to_xyz.npz")
- if not os.path.exists(xyz_map_path):
- raise FileNotFoundError(f"Missing precomputed 3D map: {xyz_map_path}")
-
- xyz_map = np.load(xyz_map_path)["xyz_map"]
- width = int(cam_data["width"])
- height = int(cam_data["height"])
- if xyz_map.shape[:2] != (height, width):
- raise ValueError(
- f"pixel_to_xyz shape {xyz_map.shape[:2]} does not match camera size {(height, width)}"
- )
-
- return {
- "vertices": mesh["vertices"],
- "faces": mesh["faces"],
- "uv_data": mesh["uv_data"],
- "loop_to_vertex": mesh["loop_to_vertex"],
- "K": cam_data["K"],
- "width": width,
- "height": height,
- "world_to_cam": np.load(os.path.join(data_dir, "world_to_cam.npy")),
- "xyz_map": xyz_map,
- }
-
-
-def parse_source(value):
- value = str(value).strip()
- for prefix in ("camera:", "cam:", "webcam:"):
- if value.lower().startswith(prefix):
- value = value[len(prefix):].strip()
- break
-
- try:
- return int(value)
- except ValueError:
- return value
-
-
-def prepare_static_points(data, display_stride):
- xyz_map = data["xyz_map"]
- height, width = xyz_map.shape[:2]
- valid_mask = ~np.isnan(xyz_map[:, :, 0])
- valid_points = xyz_map[valid_mask].reshape(-1, 3)
- if len(valid_points) == 0:
- raise ValueError("pixel_to_xyz.npz contains no valid mesh hit points.")
-
- display_indices = np.arange(len(valid_points))[::max(1, int(display_stride))]
- flat_indices = np.flatnonzero(valid_mask.ravel())[display_indices]
- display_ys = flat_indices // width
- display_xs = flat_indices % width
- return (
- valid_mask,
- display_indices,
- valid_points[display_indices],
- display_ys,
- display_xs,
- )
-
-
-def mesh_pixel_to_world_scale(xyz_map, valid_mask):
- valid_points = xyz_map[valid_mask]
- if len(valid_points) == 0:
- return 1e-3
- extent = np.nanmax(valid_points, axis=0) - np.nanmin(valid_points, axis=0)
- mesh_span = float(np.max(extent))
- height, width = xyz_map.shape[:2]
- return mesh_span / max(height, width, 1)
-
-
-def sample_field_at_display_points(field, display_ys, display_xs):
- if field.ndim == 2:
- return field[display_ys, display_xs]
- return field[display_ys, display_xs, :]
-
-
-def deform_point_cloud(rest_points, displacement, pixel_to_world, deform_scale):
- return rest_points + displacement * pixel_to_world * float(deform_scale)
-
-
-def lift_flow_to_3d(flow, tangent_x, tangent_y, normals):
- """Lift raw 2D optical flow to tangent-plane 3D displacement."""
- tx_norm = np.linalg.norm(tangent_x, axis=2, keepdims=True)
- ty_norm = np.linalg.norm(tangent_y, axis=2, keepdims=True)
- t1 = np.divide(tangent_x, tx_norm, out=np.zeros_like(tangent_x), where=tx_norm > 1e-8)
- t2 = np.divide(tangent_y, ty_norm, out=np.zeros_like(tangent_y), where=ty_norm > 1e-8)
- flow_3d = flow[:, :, 0:1] * t1 + flow[:, :, 1:2] * t2
- normal_component = np.sum(flow_3d * normals, axis=2, keepdims=True)
- return flow_3d - normal_component * normals
-
-
-def align_image_to_map(img_bgr, data, valid_mask, display_indices, flip_vertical=True):
- target_w = int(data["width"])
- target_h = int(data["height"])
- h, w = img_bgr.shape[:2]
- if (w, h) != (target_w, target_h):
- img_bgr = cv2.resize(img_bgr, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
-
- img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
- if flip_vertical:
- img_rgb = np.flipud(img_rgb).copy()
-
- colors = img_rgb.astype(np.float64) / 255.0
- return colors[valid_mask].reshape(-1, 3)[display_indices]
-
-
-def arrow_map_reference_colors(count, color=(0.04, 0.04, 0.04)):
- return np.tile(np.asarray(color, dtype=np.float64), (count, 1))
-
-
-def magnitude_map_colors(flow, valid_mask, display_indices, max_magnitude=None):
- mag = np.linalg.norm(flow, axis=2)
- valid_mag = mag[valid_mask].reshape(-1)
- display_mag = valid_mag[display_indices]
- if max_magnitude is None or max_magnitude <= 0:
- max_magnitude = max(float(np.percentile(display_mag, 95)), 1e-6)
-
- value = np.clip(display_mag / max_magnitude, 0.0, 1.0)
- return np.column_stack(
- (
- value,
- np.clip(1.0 - np.abs(value - 0.5) * 2.0, 0.0, 1.0),
- 1.0 - value,
- )
- ).astype(np.float64)
-
-
-def align_scalar_field_to_map(field, data, flip_vertical=True):
- if field.ndim == 3:
- field = np.linalg.norm(field, axis=2)
-
- target_w = int(data["width"])
- target_h = int(data["height"])
- h, w = field.shape[:2]
- if (w, h) != (target_w, target_h):
- field = cv2.resize(field, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
- else:
- field = field.copy()
-
- if flip_vertical:
- field = np.flipud(field).copy()
- return field
-
-
-def scalar_map_colors(field, valid_mask, display_indices, value_min=None, value_max=None):
- valid_values = field[valid_mask].reshape(-1)
- display_values = valid_values[display_indices]
- finite_values = display_values[np.isfinite(display_values)]
- if len(finite_values) == 0:
- return arrow_map_reference_colors(len(display_indices))
-
- if value_min is None:
- value_min = float(np.percentile(finite_values, 5))
- if value_max is None:
- value_max = float(np.percentile(finite_values, 95))
- if value_max <= value_min:
- value_max = value_min + 1e-6
-
- value = np.clip((display_values - value_min) / (value_max - value_min), 0.0, 1.0)
- return np.column_stack(
- (
- value,
- np.clip(1.0 - np.abs(value - 0.5) * 2.0, 0.0, 1.0),
- 1.0 - value,
- )
- ).astype(np.float64)
-
-
-def align_vector_field_to_map(field, data, flip_vertical=True, scale_as_flow=True):
- target_w = int(data["width"])
- target_h = int(data["height"])
- h, w = field.shape[:2]
- if (w, h) != (target_w, target_h):
- sx = target_w / max(w, 1)
- sy = target_h / max(h, 1)
- field = cv2.resize(field, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
- if scale_as_flow:
- field[:, :, 0] *= sx
- field[:, :, 1] *= sy
- else:
- field = field.copy()
-
- if flip_vertical:
- field = np.flipud(field).copy()
- field[:, :, 1] *= -1.0
- return field
-
-
-def estimate_surface_normals(xyz_map):
- valid = ~np.isnan(xyz_map[:, :, 0])
- filled = xyz_map.copy()
- filled[~valid] = 0
- d_y = np.gradient(filled, axis=0)
- d_x = np.gradient(filled, axis=1)
- normals = np.cross(d_x, d_y)
- norm = np.linalg.norm(normals, axis=2, keepdims=True)
- normals = np.divide(normals, norm, out=np.zeros_like(normals), where=norm > 1e-8)
- normals[~valid] = 0
- return normals
-
-
-def compute_surface_frames(xyz_map):
- valid = ~np.isnan(xyz_map[:, :, 0])
- filled = xyz_map.copy()
- filled[~valid] = 0
- d_y = np.gradient(filled, axis=0)
- d_x = np.gradient(filled, axis=1)
- normals = np.cross(d_x, d_y)
- norm = np.linalg.norm(normals, axis=2, keepdims=True)
- normals = np.divide(normals, norm, out=np.zeros_like(normals), where=norm > 1e-8)
- d_x[~valid] = 0
- d_y[~valid] = 0
- normals[~valid] = 0
- return d_x, d_y, normals, valid
-
-
-def lift_deformation_to_3d(vshear, vnormal, tangent_x, tangent_y, normals, normal_gain=1.0):
- # Unit tangents: map 2D pixel shear to 3D direction without world-scale Jacobian blow-up.
- tx_norm = np.linalg.norm(tangent_x, axis=2, keepdims=True)
- ty_norm = np.linalg.norm(tangent_y, axis=2, keepdims=True)
- t1 = np.divide(tangent_x, tx_norm, out=np.zeros_like(tangent_x), where=tx_norm > 1e-8)
- t2 = np.divide(tangent_y, ty_norm, out=np.zeros_like(tangent_y), where=ty_norm > 1e-8)
-
- shear_3d = vshear[:, :, 0:1] * t1 + vshear[:, :, 1:2] * t2
- normal_mag = np.linalg.norm(vnormal, axis=2, keepdims=True) * float(normal_gain)
- normal_3d = normal_mag * normals
-
- normal_component = np.sum(shear_3d * normals, axis=2, keepdims=True)
- shear_tangent = shear_3d - normal_component * normals
- return shear_tangent + normal_3d
-
-
-def line_colors(mag, arrow_map_style):
- if len(mag) == 0:
- return np.empty((0, 3), dtype=np.float64)
- if arrow_map_style:
- return np.tile(np.array([0.0, 0.9, 1.0], dtype=np.float64), (len(mag), 1))
- norm_mag = mag / max(float(np.percentile(mag, 95)), 1e-6)
- norm_mag = np.clip(norm_mag, 0.0, 1.0)
- return np.column_stack((norm_mag, 1.0 - norm_mag, np.zeros_like(norm_mag))).astype(np.float64)
-
-
-def lines_from_vectors(starts, vectors, mag, flow_scale, arrow_map_style, unit_direction=False):
- vec_norm = np.linalg.norm(vectors, axis=1)
- good = np.isfinite(vec_norm) & (vec_norm > 1e-8)
- starts, vectors, mag = starts[good], vectors[good], mag[good]
- if len(starts) == 0:
- return np.empty((0, 3)), np.empty((0, 2), dtype=np.int32), np.empty((0, 3))
-
- if unit_direction:
- vec_norm = vec_norm[:, None]
- vectors = np.divide(vectors, vec_norm, out=np.zeros_like(vectors), where=vec_norm > 1e-8)
-
- points = np.empty((len(starts) * 2, 3), dtype=np.float64)
- points[0::2] = starts
- points[1::2] = starts + vectors * flow_scale
- lines = np.column_stack((np.arange(0, len(points), 2), np.arange(1, len(points), 2))).astype(np.int32)
- return points, lines, line_colors(mag, arrow_map_style)
-
-
-def build_flow_lines(
- xyz_map,
- valid_mask,
- flow,
- arrow_stride=32,
- flow_scale=1.0,
- min_magnitude=0.5,
- arrow_map_style=False,
-):
- h, w = flow.shape[:2]
- ys, xs = np.mgrid[0:h:arrow_stride, 0:w:arrow_stride]
- ys = ys.reshape(-1)
- xs = xs.reshape(-1)
- sample_flow = flow[ys, xs]
- mag = np.linalg.norm(sample_flow, axis=1)
- keep = valid_mask[ys, xs] & (mag >= min_magnitude)
- xs, ys, sample_flow, mag = xs[keep], ys[keep], sample_flow[keep], mag[keep]
- if len(xs) == 0:
- return np.empty((0, 3)), np.empty((0, 2), dtype=np.int32), np.empty((0, 3))
-
- tx = np.rint(xs + sample_flow[:, 0]).astype(np.int32)
- ty = np.rint(ys + sample_flow[:, 1]).astype(np.int32)
- inside = (0 <= tx) & (tx < w) & (0 <= ty) & (ty < h)
- xs, ys, tx, ty, mag = xs[inside], ys[inside], tx[inside], ty[inside], mag[inside]
- if len(xs) == 0:
- return np.empty((0, 3)), np.empty((0, 2), dtype=np.int32), np.empty((0, 3))
-
- valid_target = valid_mask[ty, tx]
- xs, ys, tx, ty, mag = xs[valid_target], ys[valid_target], tx[valid_target], ty[valid_target], mag[valid_target]
- starts = xyz_map[ys, xs]
- vectors = xyz_map[ty, tx] - starts
- return lines_from_vectors(starts, vectors, mag, flow_scale, arrow_map_style)
-
-
-def build_resultant_force_lines(
- xyz_map,
- valid_mask,
- resultant_force,
- arrow_stride=32,
- flow_scale=1.0,
- min_magnitude=0.01,
- arrow_map_style=False,
-):
- h, w = resultant_force.shape[:2]
- ys, xs = np.mgrid[0:h:arrow_stride, 0:w:arrow_stride]
- ys = ys.reshape(-1)
- xs = xs.reshape(-1)
-
- vectors = resultant_force[ys, xs]
- mag = np.linalg.norm(vectors, axis=1)
- keep = valid_mask[ys, xs] & (mag >= min_magnitude)
- xs, ys, vectors, mag = (
- xs[keep],
- ys[keep],
- vectors[keep],
- mag[keep],
- )
- if len(xs) == 0:
- return np.empty((0, 3)), np.empty((0, 2), dtype=np.int32), np.empty((0, 3))
- starts = xyz_map[ys, xs]
- return lines_from_vectors(
- starts, vectors, mag, flow_scale, arrow_map_style, unit_direction=True
- )
-
-
-def init_viewer(
- cloud_points,
- cloud_colors,
- flow_points,
- flow_lines,
- flow_colors,
- arrow_map_style=False,
- show_arrows=True,
-):
- import open3d as o3d
-
- vis = o3d.visualization.Visualizer()
- vis.create_window(window_name="SDK 3D Flow", width=1280, height=720)
-
- pcd = o3d.geometry.PointCloud()
- pcd.points = o3d.utility.Vector3dVector(cloud_points.astype(np.float64))
- pcd.colors = o3d.utility.Vector3dVector(cloud_colors.astype(np.float64))
- vis.add_geometry(pcd)
-
- line_set = None
- if show_arrows:
- line_set = o3d.geometry.LineSet()
- line_set.points = o3d.utility.Vector3dVector(flow_points.astype(np.float64))
- line_set.lines = o3d.utility.Vector2iVector(flow_lines)
- line_set.colors = o3d.utility.Vector3dVector(flow_colors.astype(np.float64))
- vis.add_geometry(line_set)
-
- render_option = vis.get_render_option()
- render_option.point_size = 1.0 if arrow_map_style else 2.0
- render_option.line_width = 2.0
- render_option.background_color = np.array([0.0, 0.0, 0.0])
- vis.poll_events()
- vis.update_renderer()
- return vis, pcd, line_set, o3d
-
-
-def run_sdk_3d_flow(args):
- data = load_exported_data(args.data_dir)
- valid_mask, display_indices, rest_cloud_points, display_ys, display_xs = prepare_static_points(
- data, args.display_stride
- )
- tangent_x, tangent_y, surface_normals, valid_mask = compute_surface_frames(data["xyz_map"])
- pixel_to_world = mesh_pixel_to_world_scale(data["xyz_map"], valid_mask)
- show_arrows = args.display_mode == "arrows"
- deform_cloud = args.display_mode == "deform"
-
- sensor = orisys.Sensor(
- parse_source(args.source),
- config_name=args.config,
- isstitch=False,
- cuda=args.cuda,
- verbose=args.verbose,
- mirror=not args.no_mirror,
- )
-
- vis = pcd = line_set = o3d = None
- frame_count = 0
- t0 = time.perf_counter()
-
- try:
- while True:
- if vis is not None and not vis.poll_events():
- break
-
- if sensor.get_img() is None:
- break
- needs_decomposition = args.vector_mode in ("shear-normal", "depth-map")
- sensor.compute_deformation(decompose=needs_decomposition, check_motion=False)
- if args.vector_mode == "depth-map":
- sensor.compute_contact()
- flow, img_bgr, vshear, vnormal, depth_map = sensor.read_info(
- sensor.info.VRAW,
- sensor.info.IMG,
- sensor.info.VSHEAR,
- sensor.info.VNORMAL,
- sensor.info.DEPTH,
- )
-
- flow_map = align_vector_field_to_map(flow, data, flip_vertical=not args.no_flip_vertical)
- if args.vector_mode == "vraw-magnitude":
- cloud_colors = magnitude_map_colors(flow_map, valid_mask, display_indices, args.magnitude_max)
- elif args.vector_mode == "depth-map":
- depth_map = align_scalar_field_to_map(depth_map, data, flip_vertical=not args.no_flip_vertical)
- cloud_colors = scalar_map_colors(
- depth_map,
- valid_mask,
- display_indices,
- value_min=args.depth_min,
- value_max=args.depth_max,
- )
- elif args.arrow_map_style:
- cloud_colors = arrow_map_reference_colors(len(rest_cloud_points))
- else:
- cloud_colors = align_image_to_map(
- img_bgr,
- data,
- valid_mask,
- display_indices,
- flip_vertical=not args.no_flip_vertical,
- )
-
- flip_vertical = not args.no_flip_vertical
- displacement_map = None
- if args.vector_mode == "shear-normal":
- vshear_map = align_vector_field_to_map(vshear, data, flip_vertical=flip_vertical)
- vnormal_map = align_vector_field_to_map(vnormal, data, flip_vertical=flip_vertical)
- displacement_map = lift_deformation_to_3d(
- vshear_map,
- vnormal_map,
- tangent_x,
- tangent_y,
- surface_normals,
- normal_gain=args.normal_gain,
- )
- elif args.vector_mode == "depth-map" and deform_cloud:
- vnormal_map = align_vector_field_to_map(vnormal, data, flip_vertical=flip_vertical)
- zero_shear = np.zeros_like(vnormal_map)
- displacement_map = lift_deformation_to_3d(
- zero_shear,
- vnormal_map,
- tangent_x,
- tangent_y,
- surface_normals,
- normal_gain=args.normal_gain,
- )
- elif args.vector_mode in ("vraw", "vraw-magnitude"):
- displacement_map = lift_flow_to_3d(flow_map, tangent_x, tangent_y, surface_normals)
-
- if deform_cloud and args.deform_color == "magnitude" and displacement_map is not None:
- cloud_colors = magnitude_map_colors(
- displacement_map, valid_mask, display_indices, args.magnitude_max
- )
-
- if deform_cloud:
- if displacement_map is None:
- raise ValueError(
- f"display-mode deform is not supported with vector-mode {args.vector_mode!r}"
- )
- point_displacement = sample_field_at_display_points(
- displacement_map, display_ys, display_xs
- )
- cloud_points = deform_point_cloud(
- rest_cloud_points,
- point_displacement,
- pixel_to_world,
- args.deform_scale,
- )
- flow_points = np.empty((0, 3), dtype=np.float64)
- flow_lines = np.empty((0, 2), dtype=np.int32)
- flow_colors = np.empty((0, 3), dtype=np.float64)
- else:
- cloud_points = rest_cloud_points
- if args.vector_mode == "shear-normal":
- flow_points, flow_lines, flow_colors = build_resultant_force_lines(
- data["xyz_map"],
- valid_mask,
- displacement_map,
- arrow_stride=args.arrow_stride,
- flow_scale=args.flow_scale,
- min_magnitude=args.min_flow,
- arrow_map_style=args.arrow_map_style,
- )
- elif args.vector_mode in ("vraw-magnitude", "depth-map"):
- flow_points = np.empty((0, 3), dtype=np.float64)
- flow_lines = np.empty((0, 2), dtype=np.int32)
- flow_colors = np.empty((0, 3), dtype=np.float64)
- else:
- flow_points, flow_lines, flow_colors = build_flow_lines(
- data["xyz_map"],
- valid_mask,
- flow_map,
- arrow_stride=args.arrow_stride,
- flow_scale=args.flow_scale,
- min_magnitude=args.min_flow,
- arrow_map_style=args.arrow_map_style,
- )
-
- if vis is None:
- vis, pcd, line_set, o3d = init_viewer(
- cloud_points,
- cloud_colors,
- flow_points,
- flow_lines,
- flow_colors,
- arrow_map_style=args.arrow_map_style,
- show_arrows=show_arrows,
- )
- mode_label = "deformed cloud" if deform_cloud else "arrows"
- print(f"SDK 3D flow viewer started ({mode_label}). Close the Open3D window to stop.")
- else:
- pcd.points = o3d.utility.Vector3dVector(cloud_points.astype(np.float64))
- pcd.colors = o3d.utility.Vector3dVector(cloud_colors)
- if line_set is not None:
- line_set.points = o3d.utility.Vector3dVector(flow_points.astype(np.float64))
- line_set.lines = o3d.utility.Vector2iVector(flow_lines)
- line_set.colors = o3d.utility.Vector3dVector(flow_colors.astype(np.float64))
- vis.update_geometry(line_set)
- vis.update_geometry(pcd)
- vis.update_renderer()
-
- frame_count += 1
- if frame_count % 30 == 0:
- elapsed = max(time.perf_counter() - t0, 1e-6)
- arrow_count = len(flow_lines) if show_arrows else 0
- print(f"frames={frame_count}, viewer FPS={frame_count / elapsed:.1f}, arrows={arrow_count}")
- finally:
- sensor.disconnect()
- if vis is not None:
- vis.destroy_window()
-
-
-def parse_args():
- parser = argparse.ArgumentParser(description="Map SDK flow fields to a 3D point cloud.")
- parser.add_argument("--data-dir", default=os.path.join(SCRIPT_DIR, "exported_data"))
- parser.add_argument(
- "--source",
- default=os.path.join(SCRIPT_DIR, "20260602-094916.mp4"),
- help="Video path or live camera ID, for example 0, 1, camera:0, or cam:1.",
- )
- parser.add_argument("--config", default=os.path.join(REPO_ROOT, "config", "dd02-ov.json"))
- parser.add_argument("--display-stride", type=int, default=4)
- parser.add_argument("--arrow-stride", type=int, default=8)
- parser.add_argument(
- "--flow-scale",
- type=float,
- default=3.0,
- help="Arrow length in 3D viewer units (shear-normal uses unit direction; magnitude shown by color).",
- )
- parser.add_argument(
- "--min-flow",
- type=float,
- default=0.5,
- help="Minimum 2D-equivalent resultant magnitude before drawing an arrow.",
- )
- parser.add_argument("--normal-gain", type=float, default=1.0)
- parser.add_argument(
- "--display-mode",
- choices=["arrows", "deform"],
- default="arrows",
- help="arrows: line vectors; deform: displace the point cloud by the 3D deformation field.",
- )
- parser.add_argument(
- "--deform-scale",
- type=float,
- default=1.0,
- help="Gain on cloud displacement (multiplied by mesh pixel-to-world scale).",
- )
- parser.add_argument(
- "--deform-color",
- choices=["texture", "magnitude"],
- default="texture",
- help="Point colors in deform mode: camera texture or deformation magnitude heat-map.",
- )
- parser.add_argument("--magnitude-max", type=float, default=None,
- help="Upper VRAW magnitude for heat-map color scaling; default uses frame percentile.")
- parser.add_argument("--depth-min", type=float, default=None,
- help="Minimum depth value for heat-map color scaling; default uses frame percentile.")
- parser.add_argument("--depth-max", type=float, default=None,
- help="Maximum depth value for heat-map color scaling; default uses frame percentile.")
- parser.add_argument(
- "--vector-mode",
- choices=["vraw", "vraw-magnitude", "shear-normal", "depth-map"],
- default="vraw",
- help="Vector display mode. shear-normal shows 3D resultant vectors (surface-tangent shear + surface-normal component).",
- )
- parser.add_argument(
- "--arrow-map-style",
- action="store_true",
- help="Hide real texture and use a dark Qt-viewer-like arrow map style.",
- )
- parser.add_argument("--no-flip-vertical", action="store_true")
- parser.add_argument("--no-mirror", action="store_true")
- parser.add_argument("--cuda", action="store_true")
- parser.add_argument("--verbose", action="store_true")
- return parser.parse_args()
-
-
-if __name__ == "__main__":
- run_sdk_3d_flow(parse_args())
diff --git a/tools/dd02_3dviewer/sdk_mapto3d_flatten.py b/tools/dd02_3dviewer/sdk_mapto3d_flatten.py
deleted file mode 100644
index 919c712..0000000
--- a/tools/dd02_3dviewer/sdk_mapto3d_flatten.py
+++ /dev/null
@@ -1,564 +0,0 @@
-import argparse
-import os
-import sys
-import time
-
-import cv2
-import numpy as np
-
-
-SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
-REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
-SRC_DIR = os.path.join(REPO_ROOT, "src")
-if SRC_DIR not in sys.path:
- sys.path.insert(0, SRC_DIR)
-
-import orisys
-from orisys.unroll import UVDomain
-
-
-def load_flatten_remap(path):
- """Load flatten cache; returns dict compatible with legacy viewer code."""
- domain = UVDomain.load(path)
- result = {
- "map_x": domain.remap.map_x,
- "map_y": domain.remap.map_y,
- "valid_mask": domain.valid_mask,
- }
- if domain.surface is not None:
- result["flat_xyz"] = domain.surface.flat_xyz
- return result
-
-
-def flip_sensor_frames_vertical(sensor):
- """Flip captured BGR/gray frames to match Blender-exported map orientation."""
- if sensor._img_bgr_raw is not None:
- sensor._img_bgr_raw = cv2.flip(sensor._img_bgr_raw, 0)
- if sensor.img is not None:
- cv2.flip(sensor.img, 0, dst=sensor.img)
-
-
-def flatten_frame(frame, remap, border_value=0):
- flat = cv2.remap(
- frame,
- remap["map_x"],
- remap["map_y"],
- interpolation=cv2.INTER_LINEAR,
- borderMode=cv2.BORDER_CONSTANT,
- borderValue=border_value,
- )
- flat = flat.copy()
- flat[~remap["valid_mask"]] = border_value
- return flat
-
-
-def load_exported_data(data_dir):
- mesh = np.load(os.path.join(data_dir, "mesh.npz"))
- cam_data = np.load(os.path.join(data_dir, "camera_intrinsics.npz"))
- xyz_map_path = os.path.join(data_dir, "pixel_to_xyz.npz")
- if not os.path.exists(xyz_map_path):
- raise FileNotFoundError(f"Missing precomputed 3D map: {xyz_map_path}")
-
- xyz_map = np.load(xyz_map_path)["xyz_map"]
- width = int(cam_data["width"])
- height = int(cam_data["height"])
- if xyz_map.shape[:2] != (height, width):
- raise ValueError(
- f"pixel_to_xyz shape {xyz_map.shape[:2]} does not match camera size {(height, width)}"
- )
-
- return {
- "vertices": mesh["vertices"],
- "faces": mesh["faces"],
- "uv_data": mesh["uv_data"],
- "loop_to_vertex": mesh["loop_to_vertex"],
- "K": cam_data["K"],
- "width": width,
- "height": height,
- "world_to_cam": np.load(os.path.join(data_dir, "world_to_cam.npy")),
- "xyz_map": xyz_map,
- }
-
-
-def parse_source(value):
- value = str(value).strip()
- for prefix in ("camera:", "cam:", "webcam:"):
- if value.lower().startswith(prefix):
- value = value[len(prefix):].strip()
- break
-
- try:
- return int(value)
- except ValueError:
- return value
-
-
-def prepare_static_points(data, display_stride):
- xyz_map = data["xyz_map"]
- valid_mask = ~np.isnan(xyz_map[:, :, 0])
- valid_points = xyz_map[valid_mask].reshape(-1, 3)
- if len(valid_points) == 0:
- raise ValueError("pixel_to_xyz.npz contains no valid mesh hit points.")
-
- display_indices = np.arange(len(valid_points))[::max(1, int(display_stride))]
- return valid_mask, display_indices, valid_points[display_indices]
-
-
-def align_image_to_map(img_bgr, data, valid_mask, display_indices, flip_vertical=True, flip_horizontal=True):
- target_w = int(data["width"])
- target_h = int(data["height"])
- h, w = img_bgr.shape[:2]
- if (w, h) != (target_w, target_h):
- img_bgr = cv2.resize(img_bgr, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
-
- img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
- if flip_vertical:
- img_rgb = np.flipud(img_rgb).copy()
- if flip_horizontal:
- img_rgb = np.fliplr(img_rgb).copy()
-
- colors = img_rgb.astype(np.float64) / 255.0
- return colors[valid_mask].reshape(-1, 3)[display_indices]
-
-
-def arrow_map_reference_colors(count, color=(0.04, 0.04, 0.04)):
- return np.tile(np.asarray(color, dtype=np.float64), (count, 1))
-
-
-def magnitude_map_colors(flow, valid_mask, display_indices, max_magnitude=None):
- mag = np.linalg.norm(flow, axis=2)
- valid_mag = mag[valid_mask].reshape(-1)
- display_mag = valid_mag[display_indices]
- if max_magnitude is None or max_magnitude <= 0:
- max_magnitude = max(float(np.percentile(display_mag, 95)), 1e-6)
-
- value = np.clip(display_mag / max_magnitude, 0.0, 1.0)
- return np.column_stack(
- (
- value,
- np.clip(1.0 - np.abs(value - 0.5) * 2.0, 0.0, 1.0),
- 1.0 - value,
- )
- ).astype(np.float64)
-
-
-def align_scalar_field_to_map(field, data, flip_vertical=True):
- if field.ndim == 3:
- field = np.linalg.norm(field, axis=2)
-
- target_w = int(data["width"])
- target_h = int(data["height"])
- h, w = field.shape[:2]
- if (w, h) != (target_w, target_h):
- field = cv2.resize(field, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
- else:
- field = field.copy()
-
- if flip_vertical:
- field = np.flipud(field).copy()
- return field
-
-
-def scalar_map_colors(field, valid_mask, display_indices, value_min=None, value_max=None):
- valid_values = field[valid_mask].reshape(-1)
- display_values = valid_values[display_indices]
- finite_values = display_values[np.isfinite(display_values)]
- if len(finite_values) == 0:
- return arrow_map_reference_colors(len(display_indices))
-
- if value_min is None:
- value_min = float(np.percentile(finite_values, 5))
- if value_max is None:
- value_max = float(np.percentile(finite_values, 95))
- if value_max <= value_min:
- value_max = value_min + 1e-6
-
- value = np.clip((display_values - value_min) / (value_max - value_min), 0.0, 1.0)
- return np.column_stack(
- (
- value,
- np.clip(1.0 - np.abs(value - 0.5) * 2.0, 0.0, 1.0),
- 1.0 - value,
- )
- ).astype(np.float64)
-
-
-def align_vector_field_to_map(field, data, flip_vertical=True, scale_as_flow=True):
- target_w = int(data["width"])
- target_h = int(data["height"])
- h, w = field.shape[:2]
- if (w, h) != (target_w, target_h):
- sx = target_w / max(w, 1)
- sy = target_h / max(h, 1)
- field = cv2.resize(field, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
- if scale_as_flow:
- field[:, :, 0] *= sx
- field[:, :, 1] *= sy
- else:
- field = field.copy()
-
- if flip_vertical:
- field = np.flipud(field).copy()
- field[:, :, 1] *= -1.0
- return field
-
-
-def estimate_surface_normals(xyz_map):
- valid = ~np.isnan(xyz_map[:, :, 0])
- filled = xyz_map.copy()
- filled[~valid] = 0
- d_y = np.gradient(filled, axis=0)
- d_x = np.gradient(filled, axis=1)
- normals = np.cross(d_x, d_y)
- norm = np.linalg.norm(normals, axis=2, keepdims=True)
- normals = np.divide(normals, norm, out=np.zeros_like(normals), where=norm > 1e-8)
- normals[~valid] = 0
- return normals
-
-
-def line_colors(mag, arrow_map_style):
- if len(mag) == 0:
- return np.empty((0, 3), dtype=np.float64)
- if arrow_map_style:
- return np.tile(np.array([0.0, 0.9, 1.0], dtype=np.float64), (len(mag), 1))
- norm_mag = mag / max(float(np.percentile(mag, 95)), 1e-6)
- norm_mag = np.clip(norm_mag, 0.0, 1.0)
- return np.column_stack((norm_mag, 1.0 - norm_mag, np.zeros_like(norm_mag))).astype(np.float64)
-
-
-def lines_from_vectors(starts, vectors, mag, flow_scale, arrow_map_style):
- vec_norm = np.linalg.norm(vectors, axis=1)
- good = np.isfinite(vec_norm) & (vec_norm > 1e-8)
- starts, vectors, mag = starts[good], vectors[good], mag[good]
- if len(starts) == 0:
- return np.empty((0, 3)), np.empty((0, 2), dtype=np.int32), np.empty((0, 3))
-
- points = np.empty((len(starts) * 2, 3), dtype=np.float64)
- points[0::2] = starts
- points[1::2] = starts + vectors * flow_scale
- lines = np.column_stack((np.arange(0, len(points), 2), np.arange(1, len(points), 2))).astype(np.int32)
- return points, lines, line_colors(mag, arrow_map_style)
-
-
-def build_flow_lines(
- xyz_map,
- valid_mask,
- flow,
- arrow_stride=32,
- flow_scale=1.0,
- min_magnitude=0.5,
- arrow_map_style=False,
-):
- h, w = flow.shape[:2]
- ys, xs = np.mgrid[0:h:arrow_stride, 0:w:arrow_stride]
- ys = ys.reshape(-1)
- xs = xs.reshape(-1)
- sample_flow = flow[ys, xs]
- mag = np.linalg.norm(sample_flow, axis=1)
- keep = valid_mask[ys, xs] & (mag >= min_magnitude)
- xs, ys, sample_flow, mag = xs[keep], ys[keep], sample_flow[keep], mag[keep]
- if len(xs) == 0:
- return np.empty((0, 3)), np.empty((0, 2), dtype=np.int32), np.empty((0, 3))
-
- tx = np.rint(xs + sample_flow[:, 0]).astype(np.int32)
- ty = np.rint(ys + sample_flow[:, 1]).astype(np.int32)
- inside = (0 <= tx) & (tx < w) & (0 <= ty) & (ty < h)
- xs, ys, tx, ty, mag = xs[inside], ys[inside], tx[inside], ty[inside], mag[inside]
- if len(xs) == 0:
- return np.empty((0, 3)), np.empty((0, 2), dtype=np.int32), np.empty((0, 3))
-
- valid_target = valid_mask[ty, tx]
- xs, ys, tx, ty, mag = xs[valid_target], ys[valid_target], tx[valid_target], ty[valid_target], mag[valid_target]
- starts = xyz_map[ys, xs]
- vectors = xyz_map[ty, tx] - starts
- return lines_from_vectors(starts, vectors, mag, flow_scale, arrow_map_style)
-
-
-def build_shear_normal_lines(
- xyz_map,
- valid_mask,
- vshear,
- vnormal,
- normals,
- arrow_stride=32,
- flow_scale=1.0,
- min_magnitude=0.01,
- normal_gain=1.0,
- arrow_map_style=False,
-):
- h, w = vshear.shape[:2]
- ys, xs = np.mgrid[0:h:arrow_stride, 0:w:arrow_stride]
- ys = ys.reshape(-1)
- xs = xs.reshape(-1)
-
- shear = vshear[ys, xs]
- normal_field = vnormal[ys, xs]
- shear_mag = np.linalg.norm(shear, axis=1)
- normal_mag = np.linalg.norm(normal_field, axis=1) * normal_gain
- combined_mag = shear_mag + normal_mag
- keep = valid_mask[ys, xs] & (combined_mag >= min_magnitude)
- xs, ys, shear, shear_mag, normal_mag, combined_mag = (
- xs[keep],
- ys[keep],
- shear[keep],
- shear_mag[keep],
- normal_mag[keep],
- combined_mag[keep],
- )
- if len(xs) == 0:
- return np.empty((0, 3)), np.empty((0, 2), dtype=np.int32), np.empty((0, 3))
-
- tx = np.rint(xs + shear[:, 0]).astype(np.int32)
- ty = np.rint(ys + shear[:, 1]).astype(np.int32)
- inside = (0 <= tx) & (tx < w) & (0 <= ty) & (ty < h)
- xs, ys, tx, ty, shear_mag, normal_mag, combined_mag = (
- xs[inside],
- ys[inside],
- tx[inside],
- ty[inside],
- shear_mag[inside],
- normal_mag[inside],
- combined_mag[inside],
- )
- if len(xs) == 0:
- return np.empty((0, 3)), np.empty((0, 2), dtype=np.int32), np.empty((0, 3))
-
- valid_target = valid_mask[ty, tx]
- xs, ys, tx, ty, shear_mag, normal_mag, combined_mag = (
- xs[valid_target],
- ys[valid_target],
- tx[valid_target],
- ty[valid_target],
- shear_mag[valid_target],
- normal_mag[valid_target],
- combined_mag[valid_target],
- )
-
- starts = xyz_map[ys, xs]
- tangent = xyz_map[ty, tx] - starts
- tangent_norm = np.linalg.norm(tangent, axis=1, keepdims=True)
- tangent_unit = np.divide(tangent, tangent_norm, out=np.zeros_like(tangent), where=tangent_norm > 1e-8)
- normal_unit = normals[ys, xs]
-
- shear_ratio = shear_mag / np.maximum(combined_mag, 1e-8)
- normal_ratio = normal_mag / np.maximum(combined_mag, 1e-8)
- vectors = tangent_unit * shear_ratio[:, None] + normal_unit * normal_ratio[:, None]
- vector_norm = np.linalg.norm(vectors, axis=1, keepdims=True)
- vectors = np.divide(vectors, vector_norm, out=np.zeros_like(vectors), where=vector_norm > 1e-8)
- return lines_from_vectors(starts, vectors, combined_mag, flow_scale, arrow_map_style)
-
-
-def init_viewer(cloud_points, cloud_colors, flow_points, flow_lines, flow_colors, arrow_map_style=False):
- import open3d as o3d
-
- vis = o3d.visualization.Visualizer()
- vis.create_window(window_name="SDK 3D Flow", width=1280, height=720)
-
- pcd = o3d.geometry.PointCloud()
- pcd.points = o3d.utility.Vector3dVector(cloud_points.astype(np.float64))
- pcd.colors = o3d.utility.Vector3dVector(cloud_colors.astype(np.float64))
- vis.add_geometry(pcd)
-
- line_set = o3d.geometry.LineSet()
- line_set.points = o3d.utility.Vector3dVector(flow_points.astype(np.float64))
- line_set.lines = o3d.utility.Vector2iVector(flow_lines)
- line_set.colors = o3d.utility.Vector3dVector(flow_colors.astype(np.float64))
- vis.add_geometry(line_set)
-
- render_option = vis.get_render_option()
- render_option.point_size = 1.0 if arrow_map_style else 2.0
- render_option.line_width = 2.0
- render_option.background_color = np.array([0.0, 0.0, 0.0])
- vis.poll_events()
- vis.update_renderer()
- return vis, pcd, line_set, o3d
-
-
-def run_sdk_3d_flow(args):
- data = load_exported_data(args.data_dir)
- flatten_remap = load_flatten_remap(args.flatten_remap) if args.flatten_remap else None
- view_data = data
- if flatten_remap is not None:
- if "flat_xyz" not in flatten_remap:
- raise ValueError("Flatten remap must contain flat_xyz for 3D visualization.")
- view_data = dict(data)
- view_data["xyz_map"] = flatten_remap["flat_xyz"]
- view_data["height"], view_data["width"] = view_data["xyz_map"].shape[:2]
-
- valid_mask, display_indices, cloud_points = prepare_static_points(view_data, args.display_stride)
- surface_normals = estimate_surface_normals(view_data["xyz_map"])
- flip_vertical = not args.no_flip_vertical
-
- sensor = orisys.Sensor(
- parse_source(args.source),
- config_name=args.config,
- isstitch=False,
- cuda=args.cuda,
- verbose=args.verbose,
- mirror=not args.no_mirror,
- )
-
- vis = pcd = line_set = o3d = None
- frame_count = 0
- t0 = time.perf_counter()
-
- try:
- while True:
- if vis is not None and not vis.poll_events():
- break
-
- if sensor.get_img() is None:
- break
- if flip_vertical:
- flip_sensor_frames_vertical(sensor)
- flat_bgr = None
- if flatten_remap is not None:
- flat_bgr = flatten_frame(sensor.read_info(sensor.info.IMG)[0], flatten_remap)
- flat_gray = cv2.cvtColor(flat_bgr, cv2.COLOR_BGR2GRAY)
- sensor.img = flat_gray
- sensor._img_bgr_raw = flat_bgr
- sensor.img_size_raw = flat_bgr.shape
- needs_decomposition = args.vector_mode in ("shear-normal", "depth-map")
- sensor.compute_deformation(decompose=needs_decomposition, check_motion=False)
- if args.vector_mode == "depth-map":
- sensor.compute_contact()
- flow, img_bgr, vshear, vnormal, depth_map = sensor.read_info(
- sensor.info.VRAW,
- sensor.info.IMG,
- sensor.info.VSHEAR,
- sensor.info.VNORMAL,
- sensor.info.DEPTH,
- )
- if flat_bgr is not None:
- img_bgr = flat_bgr
-
- active_data = view_data if flatten_remap is not None else data
- # Input is already flipped above; flattened domain uses flat_xyz directly.
- flip_for_map = False if (flatten_remap is not None or flip_vertical) else True
- flow_map = align_vector_field_to_map(flow, active_data, flip_vertical=flip_for_map)
- if args.vector_mode in ("vraw-magnitude", "vraw-magnitude-arrows"):
- cloud_colors = magnitude_map_colors(flow_map, valid_mask, display_indices, args.magnitude_max)
- elif args.vector_mode == "depth-map":
- depth_map = align_scalar_field_to_map(depth_map, active_data, flip_vertical=flip_for_map)
- cloud_colors = scalar_map_colors(
- depth_map,
- valid_mask,
- display_indices,
- value_min=args.depth_min,
- value_max=args.depth_max,
- )
- elif args.arrow_map_style:
- cloud_colors = arrow_map_reference_colors(len(cloud_points))
- else:
- cloud_colors = align_image_to_map(
- img_bgr,
- active_data,
- valid_mask,
- display_indices,
- flip_vertical=flip_for_map,
- )
-
- if args.vector_mode == "shear-normal":
- vshear_map = align_vector_field_to_map(vshear, active_data, flip_vertical=flip_for_map)
- vnormal_map = align_vector_field_to_map(vnormal, active_data, flip_vertical=flip_for_map)
- flow_points, flow_lines, flow_colors = build_shear_normal_lines(
- active_data["xyz_map"],
- valid_mask,
- vshear_map,
- vnormal_map,
- surface_normals,
- arrow_stride=args.arrow_stride,
- flow_scale=args.flow_scale,
- min_magnitude=args.min_flow,
- normal_gain=args.normal_gain,
- arrow_map_style=args.arrow_map_style,
- )
- elif args.vector_mode in ("vraw-magnitude", "depth-map"):
- flow_points = np.empty((0, 3), dtype=np.float64)
- flow_lines = np.empty((0, 2), dtype=np.int32)
- flow_colors = np.empty((0, 3), dtype=np.float64)
- else:
- flow_points, flow_lines, flow_colors = build_flow_lines(
- active_data["xyz_map"],
- valid_mask,
- flow_map,
- arrow_stride=args.arrow_stride,
- flow_scale=args.flow_scale,
- min_magnitude=args.min_flow,
- arrow_map_style=args.arrow_map_style,
- )
-
- if vis is None:
- vis, pcd, line_set, o3d = init_viewer(
- cloud_points,
- cloud_colors,
- flow_points,
- flow_lines,
- flow_colors,
- arrow_map_style=args.arrow_map_style,
- )
- print("SDK 3D flow viewer started. Close the Open3D window to stop.")
- else:
- pcd.colors = o3d.utility.Vector3dVector(cloud_colors)
- line_set.points = o3d.utility.Vector3dVector(flow_points.astype(np.float64))
- line_set.lines = o3d.utility.Vector2iVector(flow_lines)
- line_set.colors = o3d.utility.Vector3dVector(flow_colors.astype(np.float64))
- vis.update_geometry(pcd)
- vis.update_geometry(line_set)
- vis.update_renderer()
-
- frame_count += 1
- if frame_count % 30 == 0:
- elapsed = max(time.perf_counter() - t0, 1e-6)
- print(f"frames={frame_count}, viewer FPS={frame_count / elapsed:.1f}, arrows={len(flow_lines)}")
- finally:
- sensor.disconnect()
- if vis is not None:
- vis.destroy_window()
-
-
-def parse_args():
- parser = argparse.ArgumentParser(description="Map SDK flow fields to a 3D point cloud.")
- parser.add_argument("--data-dir", default=os.path.join(SCRIPT_DIR, "exported_data"))
- parser.add_argument(
- "--source",
- default=os.path.join(SCRIPT_DIR, "20260602-094916.mp4"),
- help="Video path or live camera ID, for example 0, 1, camera:0, or cam:1.",
- )
- parser.add_argument("--config", default=os.path.join(REPO_ROOT, "config", "dd02-ov.json"))
- parser.add_argument("--display-stride", type=int, default=4)
- parser.add_argument("--arrow-stride", type=int, default=32)
- parser.add_argument("--flow-scale", type=float, default=3.0)
- parser.add_argument("--min-flow", type=float, default=0.5)
- parser.add_argument("--normal-gain", type=float, default=1.0)
- parser.add_argument("--magnitude-max", type=float, default=None,
- help="Upper VRAW magnitude for heat-map color scaling; default uses frame percentile.")
- parser.add_argument("--depth-min", type=float, default=None,
- help="Minimum depth value for heat-map color scaling; default uses frame percentile.")
- parser.add_argument("--depth-max", type=float, default=None,
- help="Maximum depth value for heat-map color scaling; default uses frame percentile.")
- parser.add_argument("--flatten-remap", default=None,
- help="Optional flatten_remap.npz to run optical flow in UV-flattened image space.")
- parser.add_argument(
- "--vector-mode",
- choices=["vraw", "vraw-magnitude", "vraw-magnitude-arrows", "shear-normal", "depth-map"],
- default="vraw",
- )
- parser.add_argument(
- "--arrow-map-style",
- action="store_true",
- help="Hide real texture and use a dark Qt-viewer-like arrow map style.",
- )
- parser.add_argument(
- "--no-flip-vertical",
- action="store_true",
- help="Do not flip captured video vertically before processing.",
- )
- parser.add_argument("--no-mirror", action="store_true")
- parser.add_argument("--cuda", action="store_true")
- parser.add_argument("--verbose", action="store_true")
- return parser.parse_args()
-
-
-if __name__ == "__main__":
- run_sdk_3d_flow(parse_args())
diff --git a/tools/dd02_3dviewer/texture/patch_texture_0525_N120.png b/tools/dd02_3dviewer/texture/patch_texture_0525_N120.png
deleted file mode 100644
index 769f728..0000000
Binary files a/tools/dd02_3dviewer/texture/patch_texture_0525_N120.png and /dev/null differ
diff --git a/tools/dd02_3dviewer/texture/patch_texture_0525_N60.png b/tools/dd02_3dviewer/texture/patch_texture_0525_N60.png
deleted file mode 100644
index bf4adc6..0000000
Binary files a/tools/dd02_3dviewer/texture/patch_texture_0525_N60.png and /dev/null differ
diff --git a/tools/dd02_3dviewer/texture/patch_texture_0525_N75.png b/tools/dd02_3dviewer/texture/patch_texture_0525_N75.png
deleted file mode 100644
index 5356d64..0000000
Binary files a/tools/dd02_3dviewer/texture/patch_texture_0525_N75.png and /dev/null differ
diff --git a/tools/dd02_3dviewer/texture/patch_texture_0525_N90.png b/tools/dd02_3dviewer/texture/patch_texture_0525_N90.png
deleted file mode 100644
index 5775e41..0000000
Binary files a/tools/dd02_3dviewer/texture/patch_texture_0525_N90.png and /dev/null differ
diff --git a/tools/dd02_3dviewer/uv_mesh_viewer.py b/tools/dd02_3dviewer/uv_mesh_viewer.py
deleted file mode 100644
index 8ca871a..0000000
--- a/tools/dd02_3dviewer/uv_mesh_viewer.py
+++ /dev/null
@@ -1,4 +0,0 @@
-"""Backward-compatible re-exports; prefer ``examples/viz/mesh_viewer.py`` or ``orisys.finger``."""
-
-from orisys.finger.mesh import * # noqa: F403
-from orisys.finger.viewer import * # noqa: F403
diff --git a/tools/patch_generation/3.6mm.svg b/tools/patch_generation/3.6mm.svg
deleted file mode 100644
index 7748a5e..0000000
--- a/tools/patch_generation/3.6mm.svg
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
-
diff --git a/tools/patch_generation/checker_pattern.png b/tools/patch_generation/checker_pattern.png
deleted file mode 100644
index 8051c8b..0000000
Binary files a/tools/patch_generation/checker_pattern.png and /dev/null differ
diff --git a/tools/patch_generation/cmyk_generated_rgb.png b/tools/patch_generation/cmyk_generated_rgb.png
deleted file mode 100644
index 802435a..0000000
Binary files a/tools/patch_generation/cmyk_generated_rgb.png and /dev/null differ
diff --git a/tools/patch_generation/cmyk_pattern.tiff b/tools/patch_generation/cmyk_pattern.tiff
deleted file mode 100644
index 539699f..0000000
Binary files a/tools/patch_generation/cmyk_pattern.tiff and /dev/null differ
diff --git a/tools/patch_generation/color_pattern.png b/tools/patch_generation/color_pattern.png
deleted file mode 100644
index 9733268..0000000
Binary files a/tools/patch_generation/color_pattern.png and /dev/null differ
diff --git a/tools/patch_generation/dd01.svg b/tools/patch_generation/dd01.svg
deleted file mode 100644
index 165ea5d..0000000
--- a/tools/patch_generation/dd01.svg
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
diff --git a/tools/patch_generation/dd01_checker.svg b/tools/patch_generation/dd01_checker.svg
deleted file mode 100644
index 5367551..0000000
--- a/tools/patch_generation/dd01_checker.svg
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
diff --git a/tools/patch_generation/patch_generation.py b/tools/patch_generation/patch_generation.py
deleted file mode 100644
index 931db44..0000000
--- a/tools/patch_generation/patch_generation.py
+++ /dev/null
@@ -1,53 +0,0 @@
-#%%
-import numpy as np
-import cv2
-
-def generate_color_pattern(h, w, d, r):
- # Initialize the pattern with zeros
- pattern = np.zeros((h, w, 3))
-
- # Calculate the number of patches in each dimension
- num_patches_x = h // d
- num_patches_y = w // d
-
- # Maximum number of attempts to find a suitable color
- max_attempts = 1000
-
- # Iterate over the patches
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Get the neighbors
- neighbors = []
- if i > 0:
- neighbors.append(pattern[(i-1)*d:i*d, j*d:(j+1)*d])
- if j > 0:
- neighbors.append(pattern[i*d:(i+1)*d, (j-1)*d:j*d])
-
- # Generate a new color
- for attempt in range(max_attempts):
- new_color = np.random.rand(3)
- if all(np.min((new_color - neighbor.mean(axis=(0, 1)))**2) >= r for neighbor in neighbors):
- break
- if attempt == max_attempts - 1:
- print(f"Warning: Could not find a suitable color for patch ({i}, {j}) after {max_attempts} attempts")
-
- # Apply the new color to the patch
- pattern[i*d:(i+1)*d, j*d:(j+1)*d] = new_color
-
- print('Patch ({}, {})'.format(i, j))
-
- return pattern
-
-# Test the function
-pattern = generate_color_pattern(162, 162, 3, 0.75)
-
-# Convert the pattern to a format suitable for display with OpenCV
-pattern_display = (pattern * 255).astype(np.uint8)
-
-# # Display the pattern
-# cv2.imshow('Pattern', pattern_display)
-# cv2.waitKey(0)
-# cv2.destroyAllWindows()
-
-# #%%
-cv2.imwrite('color_pattern.png', pattern_display)
diff --git a/tools/patch_generation/patch_generation_3.py b/tools/patch_generation/patch_generation_3.py
deleted file mode 100644
index ab68ca2..0000000
--- a/tools/patch_generation/patch_generation_3.py
+++ /dev/null
@@ -1,773 +0,0 @@
-#%%
-import numpy as np
-import cv2
-import matplotlib.pyplot as plt
-from PIL import Image
-
-def merge_intervals(intervals):
- # Merge overlapping intervals.
- if not intervals:
- return []
- intervals.sort(key=lambda x: x[0])
- merged = [intervals[0]]
- for current in intervals[1:]:
- last = merged[-1]
- if current[0] <= last[1]:
- merged[-1] = (last[0], max(last[1], current[1]))
- else:
- merged.append(current)
- return merged
-
-def get_allowed_intervals(neighbor_values, delta):
- # Compute forbidden intervals for this channel from neighbor values.
- forbidden = []
- for c in neighbor_values:
- low = max(0, c - delta)
- high = min(1, c + delta)
- forbidden.append((low, high))
- # Merge overlapping forbidden intervals.
- merged = merge_intervals(forbidden)
- allowed = []
- # Allowed region before the first forbidden interval.
- if merged:
- if merged[0][0] > 0:
- allowed.append((0, merged[0][0]))
- # Allowed regions between forbidden intervals.
- for i in range(1, len(merged)):
- allowed.append((merged[i-1][1], merged[i][0]))
- # Allowed region after the last forbidden interval.
- if merged[-1][1] < 1:
- allowed.append((merged[-1][1], 1))
- else:
- allowed.append((0, 1))
- return allowed
-
-def sample_from_allowed(allowed):
- # Choose a random value from the allowed intervals with probability proportional to interval length.
- lengths = [end - start for start, end in allowed]
- total_length = sum(lengths)
- if total_length <= 0:
- return None
- point = np.random.rand() * total_length
- cumulative = 0
- for (start, end), length in zip(allowed, lengths):
- cumulative += length
- if point <= cumulative:
- return np.random.uniform(start, end)
- return allowed[-1][0]
-
-def sample_channel(neighbor_values, delta):
- # Compute allowed intervals and sample a value.
- allowed = get_allowed_intervals(neighbor_values, delta)
- if not allowed:
- return None
- return sample_from_allowed(allowed)
-
-def generate_valid_color(neighbors, r):
- """
- Generate a random color that satisfies, for each neighbor,
- |R - R_neighbor| >= sqrt(r), and similarly for G and B.
- neighbors: list of neighbor colors (each is an array of shape (3,))
- r: threshold for squared difference.
- """
- delta = np.sqrt(r)
- color = np.zeros(3)
- for channel in range(3):
- neighbor_channel = [n[channel] for n in neighbors]
- sampled = sample_channel(neighbor_channel, delta)
- # If the allowed intervals are empty, use a fallback.
- if sampled is None:
- sampled = np.random.rand()
- color[channel] = sampled
- return color
-
-def generate_color_pattern(h, w, d, r):
- """
- Generate a dense color pattern.
- h, w: height and width of the output image in pixels.
- d: patch size in pixels (each patch is d x d).
- r: randomness regulation factor. For each neighbor the condition is:
- (|channel_new - channel_neighbor|)^2 >= r.
- """
- pattern = np.zeros((h, w, 3), dtype=np.float32)
- num_patches_x = h // d
- num_patches_y = w // d
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Collect neighbor patch colors (using mean because patches have uniform color).
- neighbors = []
- if i > 0:
- patch_above = pattern[(i-1)*d:i*d, j*d:(j+1)*d]
- neighbors.append(np.mean(patch_above, axis=(0, 1)))
- if j > 0:
- patch_left = pattern[i*d:(i+1)*d, (j-1)*d:j*d]
- neighbors.append(np.mean(patch_left, axis=(0, 1)))
-
- if len(neighbors) == 0:
- # For the first patch use a random color.
- new_color = np.random.rand(3)
- else:
- new_color = generate_valid_color(neighbors, r)
-
- pattern[i*d:(i+1)*d, j*d:(j+1)*d] = new_color
- # print('Patch ({}, {})'.format(i, j))
- return pattern
-
-def convert_to_grayscale(color_pattern):
- """
- Convert color pattern to grayscale using standard luminance weights.
-
- Parameters:
- color_pattern: Input color image (H x W x 3) with values in range [0, 1]
-
- Returns:
- Grayscale image (H x W) with values in range [0, 1]
- """
- # Use standard RGB to grayscale conversion: 0.299*R + 0.587*G + 0.114*B
- grayscale = 0.299 * color_pattern[:, :, 2] + 0.587 * color_pattern[:, :, 1] + 0.114 * color_pattern[:, :, 0]
- return grayscale
-
-def optimize_grayscale_patches(grayscale, d, min_diff=0.1, max_iterations=100):
- """
- Optimize grayscale image to improve value difference between neighboring patches.
-
- Parameters:
- grayscale: Input grayscale image (H x W) with values in range [0, 1]
- d: patch size in pixels (each patch is d x d)
- min_diff: minimum absolute difference required between neighboring patches
- max_iterations: maximum number of optimization iterations
-
- Returns:
- Optimized grayscale image with improved patch differences
- """
- h, w = grayscale.shape
- num_patches_x = h // d
- num_patches_y = w // d
-
- # Create optimized version
- optimized = grayscale.copy()
-
- for iteration in range(max_iterations):
- improved = False
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Get current patch value (mean of patch)
- current_patch = optimized[i*d:(i+1)*d, j*d:(j+1)*d]
- current_value = np.mean(current_patch)
-
- # Collect neighbor patch values
- neighbor_values = []
- if i > 0:
- patch_above = optimized[(i-1)*d:i*d, j*d:(j+1)*d]
- neighbor_values.append(np.mean(patch_above))
- if j > 0:
- patch_left = optimized[i*d:(i+1)*d, (j-1)*d:j*d]
- neighbor_values.append(np.mean(patch_left))
- if i < num_patches_x - 1:
- patch_below = optimized[(i+1)*d:(i+2)*d, j*d:(j+1)*d]
- neighbor_values.append(np.mean(patch_below))
- if j < num_patches_y - 1:
- patch_right = optimized[i*d:(i+1)*d, (j+1)*d:(j+2)*d]
- neighbor_values.append(np.mean(patch_right))
-
- if len(neighbor_values) == 0:
- continue
-
- # Check if current value is too close to any neighbor
- min_neighbor_diff = min([abs(current_value - nv) for nv in neighbor_values])
-
- if min_neighbor_diff < min_diff:
- # Need to adjust this patch
- # Find valid range that maintains min_diff from all neighbors
- forbidden = []
- for nv in neighbor_values:
- forbidden.append((max(0, nv - min_diff), min(1, nv + min_diff)))
-
- # Merge forbidden intervals
- merged_forbidden = merge_intervals(forbidden)
-
- # Find allowed intervals
- allowed = []
- if merged_forbidden:
- if merged_forbidden[0][0] > 0:
- allowed.append((0, merged_forbidden[0][0]))
- for k in range(1, len(merged_forbidden)):
- gap = merged_forbidden[k][0] - merged_forbidden[k-1][1]
- if gap > 0:
- allowed.append((merged_forbidden[k-1][1], merged_forbidden[k][0]))
- if merged_forbidden[-1][1] < 1:
- allowed.append((merged_forbidden[-1][1], 1))
- else:
- allowed.append((0, 1))
-
- # Sample new value from allowed intervals
- if allowed:
- new_value = sample_from_allowed(allowed)
- if new_value is not None:
- optimized[i*d:(i+1)*d, j*d:(j+1)*d] = new_value
- improved = True
-
- if not improved:
- print(f"Converged after {iteration + 1} iterations")
- break
-
- return optimized
-
-def analyze_patch_differences(image, d):
- """
- Analyze and report the minimum differences between neighboring patches.
-
- Parameters:
- image: Grayscale image (H x W) with values in range [0, 1]
- d: patch size in pixels
-
- Returns:
- Dictionary with statistics about patch differences
- """
- h, w = image.shape
- num_patches_x = h // d
- num_patches_y = w // d
-
- horizontal_diffs = []
- vertical_diffs = []
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- current_patch = image[i*d:(i+1)*d, j*d:(j+1)*d]
- current_value = np.mean(current_patch)
-
- # Check horizontal neighbor (right)
- if j < num_patches_y - 1:
- patch_right = image[i*d:(i+1)*d, (j+1)*d:(j+2)*d]
- right_value = np.mean(patch_right)
- horizontal_diffs.append(abs(current_value - right_value))
-
- # Check vertical neighbor (below)
- if i < num_patches_x - 1:
- patch_below = image[(i+1)*d:(i+2)*d, j*d:(j+1)*d]
- below_value = np.mean(patch_below)
- vertical_diffs.append(abs(current_value - below_value))
-
- stats = {
- 'min_diff': min(horizontal_diffs + vertical_diffs) if (horizontal_diffs + vertical_diffs) else 0,
- 'max_diff': max(horizontal_diffs + vertical_diffs) if (horizontal_diffs + vertical_diffs) else 0,
- 'mean_diff': np.mean(horizontal_diffs + vertical_diffs) if (horizontal_diffs + vertical_diffs) else 0,
- 'median_diff': np.median(horizontal_diffs + vertical_diffs) if (horizontal_diffs + vertical_diffs) else 0,
- }
-
- return stats
-
-def rgb_to_cmyk(rgb_image):
- """
- Convert RGB image to CMYK color space for printing.
-
- Parameters:
- rgb_image: RGB image (H x W x 3) with values in range [0, 1], BGR order (OpenCV format)
-
- Returns:
- CMYK image (H x W x 4) with values in range [0, 1]
- """
- # OpenCV uses BGR, so we need to handle it as B, G, R in channels 0, 1, 2
- b = rgb_image[:, :, 0]
- g = rgb_image[:, :, 1]
- r = rgb_image[:, :, 2]
-
- # Calculate K (black) channel
- k = 1 - np.maximum(np.maximum(r, g), b)
-
- # Avoid division by zero
- k_inv = 1 - k
- k_inv = np.where(k_inv == 0, 1e-10, k_inv)
-
- # Calculate CMY channels
- c = (1 - r - k) / k_inv
- m = (1 - g - k) / k_inv
- y = (1 - b - k) / k_inv
-
- # Stack into CMYK image
- cmyk = np.stack([c, m, y, k], axis=2)
- cmyk = np.clip(cmyk, 0, 1)
-
- return cmyk
-
-def cmyk_to_rgb(cmyk_image):
- """
- Convert CMYK image back to RGB color space.
-
- Parameters:
- cmyk_image: CMYK image (H x W x 4) with values in range [0, 1]
-
- Returns:
- RGB image (H x W x 3) with values in range [0, 1], BGR order (OpenCV format)
- """
- c = cmyk_image[:, :, 0]
- m = cmyk_image[:, :, 1]
- y = cmyk_image[:, :, 2]
- k = cmyk_image[:, :, 3]
-
- # Convert back to RGB
- r = (1 - c) * (1 - k)
- g = (1 - m) * (1 - k)
- b = (1 - y) * (1 - k)
-
- # Stack in BGR order for OpenCV
- rgb = np.stack([b, g, r], axis=2)
- rgb = np.clip(rgb, 0, 1)
-
- return rgb
-
-def optimize_cmyk_patches(cmyk_image, d, min_diff=0.1, max_iterations=100):
- """
- Optimize CMYK image to improve color difference between neighboring patches.
- Uses Euclidean distance in CMYK space.
-
- Parameters:
- cmyk_image: Input CMYK image (H x W x 4) with values in range [0, 1]
- d: patch size in pixels (each patch is d x d)
- min_diff: minimum Euclidean distance required between neighboring patches in CMYK space
- max_iterations: maximum number of optimization iterations
-
- Returns:
- Optimized CMYK image with improved patch differences
- """
- h, w = cmyk_image.shape[:2]
- num_patches_x = h // d
- num_patches_y = w // d
-
- optimized = cmyk_image.copy()
-
- for iteration in range(max_iterations):
- improved = False
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Get current patch CMYK values (mean of patch)
- current_patch = optimized[i*d:(i+1)*d, j*d:(j+1)*d]
- current_cmyk = np.mean(current_patch, axis=(0, 1))
-
- # Collect neighbor patch CMYK values
- neighbor_cmyk = []
- if i > 0:
- patch_above = optimized[(i-1)*d:i*d, j*d:(j+1)*d]
- neighbor_cmyk.append(np.mean(patch_above, axis=(0, 1)))
- if j > 0:
- patch_left = optimized[i*d:(i+1)*d, (j-1)*d:j*d]
- neighbor_cmyk.append(np.mean(patch_left, axis=(0, 1)))
- if i < num_patches_x - 1:
- patch_below = optimized[(i+1)*d:(i+2)*d, j*d:(j+1)*d]
- neighbor_cmyk.append(np.mean(patch_below, axis=(0, 1)))
- if j < num_patches_y - 1:
- patch_right = optimized[i*d:(i+1)*d, (j+1)*d:(j+2)*d]
- neighbor_cmyk.append(np.mean(patch_right, axis=(0, 1)))
-
- if len(neighbor_cmyk) == 0:
- continue
-
- # Calculate minimum Euclidean distance to neighbors in CMYK space
- distances = [np.linalg.norm(current_cmyk - n) for n in neighbor_cmyk]
- min_distance = min(distances)
-
- if min_distance < min_diff:
- # Need to adjust this patch - try to move it away from neighbors
- # Find the direction away from the closest neighbor
- closest_idx = np.argmin(distances)
- closest_neighbor = neighbor_cmyk[closest_idx]
-
- # Move away from closest neighbor
- direction = current_cmyk - closest_neighbor
- direction_norm = np.linalg.norm(direction)
-
- if direction_norm > 0:
- direction = direction / direction_norm
- # Move by the deficit amount
- move_amount = (min_diff - min_distance) * 1.2 # 1.2 factor for faster convergence
- new_cmyk = current_cmyk + direction * move_amount
- new_cmyk = np.clip(new_cmyk, 0, 1)
-
- # Check if new value is better
- new_distances = [np.linalg.norm(new_cmyk - n) for n in neighbor_cmyk]
- new_min_distance = min(new_distances)
-
- if new_min_distance > min_distance:
- optimized[i*d:(i+1)*d, j*d:(j+1)*d] = new_cmyk
- improved = True
-
- if not improved:
- print(f"CMYK optimization converged after {iteration + 1} iterations")
- break
-
- return optimized
-
-def analyze_cmyk_patch_differences(cmyk_image, d):
- """
- Analyze CMYK patch differences using Euclidean distance in CMYK space.
-
- Parameters:
- cmyk_image: CMYK image (H x W x 4) with values in range [0, 1]
- d: patch size in pixels
-
- Returns:
- Dictionary with statistics about CMYK patch differences
- """
- h, w = cmyk_image.shape[:2]
- num_patches_x = h // d
- num_patches_y = w // d
-
- horizontal_diffs = []
- vertical_diffs = []
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- current_patch = cmyk_image[i*d:(i+1)*d, j*d:(j+1)*d]
- current_cmyk = np.mean(current_patch, axis=(0, 1))
-
- # Check horizontal neighbor (right)
- if j < num_patches_y - 1:
- patch_right = cmyk_image[i*d:(i+1)*d, (j+1)*d:(j+2)*d]
- right_cmyk = np.mean(patch_right, axis=(0, 1))
- horizontal_diffs.append(np.linalg.norm(current_cmyk - right_cmyk))
-
- # Check vertical neighbor (below)
- if i < num_patches_x - 1:
- patch_below = cmyk_image[(i+1)*d:(i+2)*d, j*d:(j+1)*d]
- below_cmyk = np.mean(patch_below, axis=(0, 1))
- vertical_diffs.append(np.linalg.norm(current_cmyk - below_cmyk))
-
- all_diffs = horizontal_diffs + vertical_diffs
- stats = {
- 'min_diff': min(all_diffs) if all_diffs else 0,
- 'max_diff': max(all_diffs) if all_diffs else 0,
- 'mean_diff': np.mean(all_diffs) if all_diffs else 0,
- 'median_diff': np.median(all_diffs) if all_diffs else 0,
- }
-
- return stats
-
-def grayscale_to_cmyk_optimized(grayscale_target, d, min_cmyk_diff=0.2, max_iterations=100, color_diversity=0.3):
- """
- Generate a CMYK image that when converted to grayscale matches the target grayscale,
- while maintaining good color separation between patches.
-
- Parameters:
- grayscale_target: Target grayscale image (H x W) with values in range [0, 1]
- d: patch size in pixels
- min_cmyk_diff: minimum Euclidean distance required between neighboring patches in CMYK space
- max_iterations: maximum number of optimization iterations
- color_diversity: amount of color diversity to inject (0.0 to 1.0)
-
- Returns:
- CMYK image (H x W x 4) optimized for printing
- """
- h, w = grayscale_target.shape
- num_patches_x = h // d
- num_patches_y = w // d
-
- # Initialize CMYK image
- cmyk_image = np.zeros((h, w, 4), dtype=np.float32)
-
- # Generate initial CMYK values for each patch
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Get target grayscale value for this patch
- target_gray = np.mean(grayscale_target[i*d:(i+1)*d, j*d:(j+1)*d])
-
- # Generate random CMY values with diversity
- c = np.random.rand() * color_diversity
- m = np.random.rand() * color_diversity
- y = np.random.rand() * color_diversity
-
- # Calculate K based on target grayscale
- # Grayscale from CMYK: gray ≈ 1 - K when CMY are small
- # For more accurate: gray = (1-C)*(1-K)*0.114 + (1-M)*(1-K)*0.587 + (1-Y)*(1-K)*0.299
- k = 1 - target_gray # Initial approximation
-
- # Adjust to match target grayscale
- # Create temporary CMYK and test
- temp_cmyk = np.array([c, m, y, k])
- temp_rgb = np.array([(1-c)*(1-k), (1-m)*(1-k), (1-y)*(1-k)]) # R, G, B
- temp_gray = 0.299 * temp_rgb[0] + 0.587 * temp_rgb[1] + 0.114 * temp_rgb[2]
-
- # Adjust K to match target
- k_adjustment = target_gray - temp_gray
- k = np.clip(k - k_adjustment, 0, 1)
-
- cmyk_image[i*d:(i+1)*d, j*d:(j+1)*d] = [c, m, y, k]
-
- # Optimize CMYK to improve patch differences while maintaining grayscale target
- print("Optimizing CMYK for color separation while matching grayscale...")
- for iteration in range(max_iterations):
- improved = False
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Get current patch
- current_patch = cmyk_image[i*d:(i+1)*d, j*d:(j+1)*d]
- current_cmyk = np.mean(current_patch, axis=(0, 1))
-
- # Get target grayscale for this patch
- target_gray = np.mean(grayscale_target[i*d:(i+1)*d, j*d:(j+1)*d])
-
- # Collect neighbor CMYK values
- neighbor_cmyk = []
- if i > 0:
- neighbor_cmyk.append(np.mean(cmyk_image[(i-1)*d:i*d, j*d:(j+1)*d], axis=(0, 1)))
- if j > 0:
- neighbor_cmyk.append(np.mean(cmyk_image[i*d:(i+1)*d, (j-1)*d:j*d], axis=(0, 1)))
- if i < num_patches_x - 1:
- neighbor_cmyk.append(np.mean(cmyk_image[(i+1)*d:(i+2)*d, j*d:(j+1)*d], axis=(0, 1)))
- if j < num_patches_y - 1:
- neighbor_cmyk.append(np.mean(cmyk_image[i*d:(i+1)*d, (j+1)*d:(j+2)*d], axis=(0, 1)))
-
- if len(neighbor_cmyk) == 0:
- continue
-
- # Check CMYK distance to neighbors
- distances = [np.linalg.norm(current_cmyk - n) for n in neighbor_cmyk]
- min_distance = min(distances)
-
- if min_distance < min_cmyk_diff:
- # Try to improve color separation
- # Vary CMY channels while keeping grayscale constant
- for attempt in range(5):
- # Generate new CMY values
- new_c = np.clip(current_cmyk[0] + (np.random.rand() - 0.5) * 0.2, 0, 1)
- new_m = np.clip(current_cmyk[1] + (np.random.rand() - 0.5) * 0.2, 0, 1)
- new_y = np.clip(current_cmyk[2] + (np.random.rand() - 0.5) * 0.2, 0, 1)
-
- # Calculate K to maintain grayscale
- # gray = (1-C)*(1-K)*0.114 + (1-M)*(1-K)*0.587 + (1-Y)*(1-K)*0.299
- # gray = (1-K) * [0.114*(1-C) + 0.587*(1-M) + 0.299*(1-Y)]
- rgb_factor = 0.114*(1-new_c) + 0.587*(1-new_m) + 0.299*(1-new_y)
- if rgb_factor > 0:
- new_k = 1 - (target_gray / rgb_factor)
- new_k = np.clip(new_k, 0, 1)
- else:
- new_k = current_cmyk[3]
-
- new_cmyk = np.array([new_c, new_m, new_y, new_k])
-
- # Verify grayscale match
- test_rgb = np.array([(1-new_c)*(1-new_k), (1-new_m)*(1-new_k), (1-new_y)*(1-new_k)])
- test_gray = 0.299 * test_rgb[0] + 0.587 * test_rgb[1] + 0.114 * test_rgb[2]
-
- # Check if grayscale is close enough and CMYK distance improved
- if abs(test_gray - target_gray) < 0.05: # Grayscale tolerance
- new_distances = [np.linalg.norm(new_cmyk - n) for n in neighbor_cmyk]
- new_min_distance = min(new_distances)
-
- if new_min_distance > min_distance:
- cmyk_image[i*d:(i+1)*d, j*d:(j+1)*d] = new_cmyk
- improved = True
- break
-
- if not improved:
- print(f"Grayscale-to-CMYK optimization converged after {iteration + 1} iterations")
- break
-
- return cmyk_image
-
-def cmyk_to_grayscale(cmyk_image):
- """
- Convert CMYK image to grayscale.
-
- Parameters:
- cmyk_image: CMYK image (H x W x 4) with values in range [0, 1]
-
- Returns:
- Grayscale image (H x W) with values in range [0, 1]
- """
- # Convert to RGB first, then to grayscale
- rgb = cmyk_to_rgb(cmyk_image)
- grayscale = 0.299 * rgb[:, :, 2] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 0]
- return grayscale
-
-def main():
- # Parameters for the pattern.
- # Image dimensions in millimeters
- width_mm = 50 # Width in millimeters
- height_mm = 50 # Height in millimeters
- dpi = 600 # Dots per inch for printing
- patch_size = 1 # 3 for 300dpi, 6 for 600dpi
-
- # Convert mm to pixels: pixels = (mm / 25.4) * dpi
- width = int((width_mm / 25.4) * dpi)
- height = int((height_mm / 25.4) * dpi)
-
- print(f"Image dimensions: {width_mm}mm x {height_mm}mm at {dpi} DPI")
- print(f"Pixel dimensions: {width} x {height} pixels")
-
-
- # Set r to a value that gives sufficient difference.
- # Note: r is the threshold for squared difference per channel, and the condition is
- # |c - c_neighbor| >= sqrt(r). Very high r may make it impossible to satisfy.
- r = 1
-
- # Step 1: Generate initial color pattern
- print("Generating color pattern...")
- pattern = generate_color_pattern(height, width, patch_size, r)
- pattern_display = (pattern * 255).astype(np.uint8)
-
- # Step 2: Convert to grayscale
- print("\nConverting to grayscale...")
- grayscale = convert_to_grayscale(pattern)
- grayscale_display = (grayscale * 255).astype(np.uint8)
-
- # Step 3: Analyze original grayscale differences
- print("\nAnalyzing original grayscale patch differences...")
- original_stats = analyze_patch_differences(grayscale, patch_size)
- print(f"Original - Min: {original_stats['min_diff']:.4f}, Max: {original_stats['max_diff']:.4f}, "
- f"Mean: {original_stats['mean_diff']:.4f}, Median: {original_stats['median_diff']:.4f}")
-
- # Step 4: Optimize grayscale to improve patch differences
- print("\nOptimizing grayscale patch differences...")
- min_diff_threshold = 0.15 # Adjust this value to control minimum difference
- optimized_grayscale = optimize_grayscale_patches(grayscale, patch_size, min_diff=min_diff_threshold, max_iterations=50)
- optimized_display = (optimized_grayscale * 255).astype(np.uint8)
-
- # Step 5: Analyze optimized grayscale differences
- print("\nAnalyzing optimized grayscale patch differences...")
- optimized_stats = analyze_patch_differences(optimized_grayscale, patch_size)
- print(f"Optimized - Min: {optimized_stats['min_diff']:.4f}, Max: {optimized_stats['max_diff']:.4f}, "
- f"Mean: {optimized_stats['mean_diff']:.4f}, Median: {optimized_stats['median_diff']:.4f}")
-
- # Step 6: Generate CMYK image from optimized grayscale
- print("\n" + "="*60)
- print("GENERATING CMYK FROM OPTIMIZED GRAYSCALE")
- print("="*60)
- print("\nGenerating CMYK image to match optimized grayscale...")
- cmyk_from_grayscale = grayscale_to_cmyk_optimized(
- optimized_grayscale,
- patch_size,
- min_cmyk_diff=0.2,
- max_iterations=50,
- color_diversity=0.5
- )
-
- # Analyze CMYK differences
- print("\nAnalyzing CMYK patch differences...")
- cmyk_stats = analyze_cmyk_patch_differences(cmyk_from_grayscale, patch_size)
- print(f"CMYK - Min: {cmyk_stats['min_diff']:.4f}, Max: {cmyk_stats['max_diff']:.4f}, "
- f"Mean: {cmyk_stats['mean_diff']:.4f}, Median: {cmyk_stats['median_diff']:.4f}")
-
- # Convert CMYK back to RGB for display
- rgb_from_cmyk = cmyk_to_rgb(cmyk_from_grayscale)
- rgb_from_cmyk_display = (rgb_from_cmyk * 255).astype(np.uint8)
-
- # Convert CMYK back to grayscale to verify
- grayscale_from_cmyk = cmyk_to_grayscale(cmyk_from_grayscale)
- grayscale_from_cmyk_display = (grayscale_from_cmyk * 255).astype(np.uint8)
-
- # Analyze how well grayscale was preserved
- grayscale_from_cmyk_stats = analyze_patch_differences(grayscale_from_cmyk, patch_size)
- print(f"Grayscale from CMYK - Min: {grayscale_from_cmyk_stats['min_diff']:.4f}")
-
- # Calculate grayscale reconstruction error
- gray_error = np.mean(np.abs(optimized_grayscale - grayscale_from_cmyk))
- print(f"Grayscale reconstruction error: {gray_error:.4f}")
-
- # Display all images with matplotlib
- fig, axes = plt.subplots(2, 3, figsize=(18, 12), dpi=300)
-
- # Row 1: Original grayscale optimization
- # Color pattern (convert BGR to RGB for matplotlib)
- pattern_rgb = cv2.cvtColor(pattern_display, cv2.COLOR_BGR2RGB)
- axes[0, 0].imshow(pattern_rgb)
- axes[0, 0].set_title(f'1. Original Color Pattern\nSize: {height}x{width} pixels', fontsize=10)
- axes[0, 0].axis('on')
- axes[0, 0].grid(True, alpha=0.3)
-
- # Grayscale from color
- axes[0, 1].imshow(grayscale_display, cmap='gray', vmin=0, vmax=255)
- axes[0, 1].set_title(f'2. Grayscale (from color)\nSize: {height}x{width} pixels\nMin diff: {original_stats["min_diff"]:.4f}', fontsize=10)
- axes[0, 1].axis('on')
- axes[0, 1].grid(True, alpha=0.3)
-
- # Optimized grayscale
- axes[0, 2].imshow(optimized_display, cmap='gray', vmin=0, vmax=255)
- axes[0, 2].set_title(f'3. Optimized Grayscale (Target)\nSize: {height}x{width} pixels\nMin diff: {optimized_stats["min_diff"]:.4f}', fontsize=10, fontweight='bold')
- axes[0, 2].axis('on')
- axes[0, 2].grid(True, alpha=0.3)
-
- # Row 2: CMYK generation from optimized grayscale
- # RGB from CMYK
- rgb_from_cmyk_viz = cv2.cvtColor(rgb_from_cmyk_display, cv2.COLOR_BGR2RGB)
- axes[1, 0].imshow(rgb_from_cmyk_viz)
- axes[1, 0].set_title(f'4. RGB from Generated CMYK\nCMYK Min diff: {cmyk_stats["min_diff"]:.4f}\n(Best for printing)', fontsize=10, fontweight='bold')
- axes[1, 0].axis('on')
- axes[1, 0].grid(True, alpha=0.3)
-
- # Grayscale from CMYK (verification)
- axes[1, 1].imshow(grayscale_from_cmyk_display, cmap='gray', vmin=0, vmax=255)
- axes[1, 1].set_title(f'5. Grayscale from CMYK\nMin diff: {grayscale_from_cmyk_stats["min_diff"]:.4f}\nError: {gray_error:.4f}', fontsize=10)
- axes[1, 1].axis('on')
- axes[1, 1].grid(True, alpha=0.3)
-
- # Show CMYK channels visualization
- cmyk_viz = np.zeros((height, width, 3), dtype=np.uint8)
- cmyk_viz[:, :, 0] = (cmyk_from_grayscale[:, :, 0] * 255).astype(np.uint8) # Cyan in B channel
- cmyk_viz[:, :, 1] = (cmyk_from_grayscale[:, :, 1] * 255).astype(np.uint8) # Magenta in G channel
- cmyk_viz[:, :, 2] = (cmyk_from_grayscale[:, :, 2] * 255).astype(np.uint8) # Yellow in R channel
- axes[1, 2].imshow(cv2.cvtColor(cmyk_viz, cv2.COLOR_BGR2RGB))
- axes[1, 2].set_title(f'6. CMYK Channels (CMY only)\nK channel separate', fontsize=10)
- axes[1, 2].axis('on')
- axes[1, 2].grid(True, alpha=0.3)
-
- plt.tight_layout()
- print(f"\nDisplay info: Figure DPI={fig.dpi}, Size={fig.get_size_inches()} inches")
- plt.show()
-
- # Save all images
- cv2.imwrite('color_pattern.png', pattern_display)
- cv2.imwrite('grayscale_from_color.png', grayscale_display)
- cv2.imwrite('optimized_grayscale.png', optimized_display)
- cv2.imwrite('cmyk_generated_rgb.png', rgb_from_cmyk_display)
- cv2.imwrite('grayscale_from_cmyk.png', grayscale_from_cmyk_display)
-
- # Save CMYK data
- np.save('cmyk_from_grayscale.npy', cmyk_from_grayscale)
-
- # Save CMYK as TIFF (proper format for printing)
- print("\nSaving CMYK as TIFF...")
- try:
- # Convert CMYK to uint8 (0-255 range)
- cmyk_uint8 = (cmyk_from_grayscale * 255).astype(np.uint8)
-
- # For TIFF CMYK, we need to ensure proper channel order
- # PIL CMYK expects channels in C, M, Y, K order
- # Split channels
- c_channel = cmyk_uint8[:, :, 0]
- m_channel = cmyk_uint8[:, :, 1]
- y_channel = cmyk_uint8[:, :, 2]
- k_channel = cmyk_uint8[:, :, 3]
-
- # Create PIL images for each channel and merge
- c_img = Image.fromarray(c_channel, mode='L')
- m_img = Image.fromarray(m_channel, mode='L')
- y_img = Image.fromarray(y_channel, mode='L')
- k_img = Image.fromarray(k_channel, mode='L')
-
- # Merge into CMYK image
- cmyk_pil = Image.merge('CMYK', (c_img, m_img, y_img, k_img))
-
- # Save as TIFF with CMYK color space at 300 DPI for professional printing
- cmyk_pil.save('cmyk_pattern.tiff', format='TIFF', dpi=(300, 300))
-
- # Verify the TIFF was saved by checking file size
- import os
- if os.path.exists('cmyk_pattern.tiff'):
- file_size = os.path.getsize('cmyk_pattern.tiff')
- print(f"CMYK TIFF saved successfully: {file_size} bytes at 300 DPI")
-
- except Exception as e:
- print(f"Error saving CMYK TIFF: {e}")
-
- print("\nImages saved successfully!")
- print("CMYK-generated RGB: cmyk_generated_rgb.png")
- print("Grayscale verification: grayscale_from_cmyk.png")
- print("CMYK data: cmyk_from_grayscale.npy")
- print("CMYK TIFF (300 DPI): cmyk_pattern.tiff (professional printing format)")
-
-if __name__ == '__main__':
- main()
diff --git a/tools/patch_generation/patch_generation_3_org.py b/tools/patch_generation/patch_generation_3_org.py
deleted file mode 100644
index 0a09359..0000000
--- a/tools/patch_generation/patch_generation_3_org.py
+++ /dev/null
@@ -1,140 +0,0 @@
-#%%
-import numpy as np
-import cv2
-
-def merge_intervals(intervals):
- # Merge overlapping intervals.
- if not intervals:
- return []
- intervals.sort(key=lambda x: x[0])
- merged = [intervals[0]]
- for current in intervals[1:]:
- last = merged[-1]
- if current[0] <= last[1]:
- merged[-1] = (last[0], max(last[1], current[1]))
- else:
- merged.append(current)
- return merged
-
-def get_allowed_intervals(neighbor_values, delta):
- # Compute forbidden intervals for this channel from neighbor values.
- forbidden = []
- for c in neighbor_values:
- low = max(0, c - delta)
- high = min(1, c + delta)
- forbidden.append((low, high))
- # Merge overlapping forbidden intervals.
- merged = merge_intervals(forbidden)
- allowed = []
- # Allowed region before the first forbidden interval.
- if merged:
- if merged[0][0] > 0:
- allowed.append((0, merged[0][0]))
- # Allowed regions between forbidden intervals.
- for i in range(1, len(merged)):
- allowed.append((merged[i-1][1], merged[i][0]))
- # Allowed region after the last forbidden interval.
- if merged[-1][1] < 1:
- allowed.append((merged[-1][1], 1))
- else:
- allowed.append((0, 1))
- return allowed
-
-def sample_from_allowed(allowed):
- # Choose a random value from the allowed intervals with probability proportional to interval length.
- lengths = [end - start for start, end in allowed]
- total_length = sum(lengths)
- if total_length <= 0:
- return None
- point = np.random.rand() * total_length
- cumulative = 0
- for (start, end), length in zip(allowed, lengths):
- cumulative += length
- if point <= cumulative:
- return np.random.uniform(start, end)
- return allowed[-1][0]
-
-def sample_channel(neighbor_values, delta):
- # Compute allowed intervals and sample a value.
- allowed = get_allowed_intervals(neighbor_values, delta)
- if not allowed:
- return None
- return sample_from_allowed(allowed)
-
-def generate_valid_color(neighbors, r):
- """
- Generate a random color that satisfies, for each neighbor,
- |R - R_neighbor| >= sqrt(r), and similarly for G and B.
- neighbors: list of neighbor colors (each is an array of shape (3,))
- r: threshold for squared difference.
- """
- delta = np.sqrt(r)
- color = np.zeros(3)
- for channel in range(3):
- neighbor_channel = [n[channel] for n in neighbors]
- sampled = sample_channel(neighbor_channel, delta)
- # If the allowed intervals are empty, use a fallback.
- if sampled is None:
- sampled = np.random.rand()
- color[channel] = sampled
- return color
-
-def generate_color_pattern(h, w, d, r):
- """
- Generate a dense color pattern.
- h, w: height and width of the output image in pixels.
- d: patch size in pixels (each patch is d x d).
- r: randomness regulation factor. For each neighbor the condition is:
- (|channel_new - channel_neighbor|)^2 >= r.
- """
- pattern = np.zeros((h, w, 3), dtype=np.float32)
- num_patches_x = h // d
- num_patches_y = w // d
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Collect neighbor patch colors (using mean because patches have uniform color).
- neighbors = []
- if i > 0:
- patch_above = pattern[(i-1)*d:i*d, j*d:(j+1)*d]
- neighbors.append(np.mean(patch_above, axis=(0, 1)))
- if j > 0:
- patch_left = pattern[i*d:(i+1)*d, (j-1)*d:j*d]
- neighbors.append(np.mean(patch_left, axis=(0, 1)))
-
- if len(neighbors) == 0:
- # For the first patch use a random color.
- new_color = np.random.rand(3)
- else:
- new_color = generate_valid_color(neighbors, r)
-
- pattern[i*d:(i+1)*d, j*d:(j+1)*d] = new_color
- print('Patch ({}, {})'.format(i, j))
- return pattern
-
-def main():
- # Parameters for the pattern.
- # For example, using 100x100 pixels image with patches of 3x3 pixels.
- # 11111111
- height = 1200
- width = 1000
- patch_size = 12
- # Set r to a value that gives sufficient difference.
- # Note: r is the threshold for squared difference per channel, and the condition is
- # |c - c_neighbor| >= sqrt(r). Very high r may make it impossible to satisfy.
- r = 1000
-
- pattern = generate_color_pattern(height, width, patch_size, r)
- # Scale to 0-255 for display and saving.
- pattern_display = (pattern * 255).astype(np.uint8)
-
- # Display the pattern with OpenCV.
- cv2.imshow('Pattern', pattern_display)
- cv2.waitKey(0)
- cv2.destroyAllWindows()
-
- # Save the pattern as an image file.
- cv2.imwrite('color_pattern.png', pattern_display)
-
-if __name__ == '__main__':
- main()
diff --git a/tools/patch_generation/patchgen_DD01 checker.py b/tools/patch_generation/patchgen_DD01 checker.py
deleted file mode 100644
index 3105639..0000000
--- a/tools/patch_generation/patchgen_DD01 checker.py
+++ /dev/null
@@ -1,46 +0,0 @@
-#%%
-import numpy as np
-import cv2
-
-
-def generate_checker_pattern(height, width, patch_size):
- """
- Generate a black-and-white checkerboard pattern.
-
- Each `patch_size x patch_size` block alternates between black and white.
- Partial blocks at the image borders are also filled correctly.
- """
- pattern = np.zeros((height, width, 3), dtype=np.uint8)
-
- num_patches_x = (height + patch_size - 1) // patch_size
- num_patches_y = (width + patch_size - 1) // patch_size
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- color = 255 if (i + j) % 2 == 0 else 0
- y0 = i * patch_size
- y1 = min((i + 1) * patch_size, height)
- x0 = j * patch_size
- x1 = min((j + 1) * patch_size, width)
- pattern[y0:y1, x0:x1] = color
- print("Patch ({}, {}) -> {}".format(i, j, "white" if color == 255 else "black"))
-
- return pattern
-
-
-def main():
- height = 1200
- width = 1000
- patch_size = 12
-
- pattern = generate_checker_pattern(height, width, patch_size)
-
- cv2.imshow("Checker Pattern", pattern)
- cv2.waitKey(0)
- cv2.destroyAllWindows()
-
- cv2.imwrite("checker_pattern.png", pattern)
-
-
-if __name__ == '__main__':
- main()
diff --git a/tools/patch_generation/patchgen_DD01.py b/tools/patch_generation/patchgen_DD01.py
deleted file mode 100644
index ca60363..0000000
--- a/tools/patch_generation/patchgen_DD01.py
+++ /dev/null
@@ -1,139 +0,0 @@
-#%%
-import numpy as np
-import cv2
-
-def merge_intervals(intervals):
- # Merge overlapping intervals.
- if not intervals:
- return []
- intervals.sort(key=lambda x: x[0])
- merged = [intervals[0]]
- for current in intervals[1:]:
- last = merged[-1]
- if current[0] <= last[1]:
- merged[-1] = (last[0], max(last[1], current[1]))
- else:
- merged.append(current)
- return merged
-
-def get_allowed_intervals(neighbor_values, delta):
- # Compute forbidden intervals for this channel from neighbor values.
- forbidden = []
- for c in neighbor_values:
- low = max(0, c - delta)
- high = min(1, c + delta)
- forbidden.append((low, high))
- # Merge overlapping forbidden intervals.
- merged = merge_intervals(forbidden)
- allowed = []
- # Allowed region before the first forbidden interval.
- if merged:
- if merged[0][0] > 0:
- allowed.append((0, merged[0][0]))
- # Allowed regions between forbidden intervals.
- for i in range(1, len(merged)):
- allowed.append((merged[i-1][1], merged[i][0]))
- # Allowed region after the last forbidden interval.
- if merged[-1][1] < 1:
- allowed.append((merged[-1][1], 1))
- else:
- allowed.append((0, 1))
- return allowed
-
-def sample_from_allowed(allowed):
- # Choose a random value from the allowed intervals with probability proportional to interval length.
- lengths = [end - start for start, end in allowed]
- total_length = sum(lengths)
- if total_length <= 0:
- return None
- point = np.random.rand() * total_length
- cumulative = 0
- for (start, end), length in zip(allowed, lengths):
- cumulative += length
- if point <= cumulative:
- return np.random.uniform(start, end)
- return allowed[-1][0]
-
-def sample_channel(neighbor_values, delta):
- # Compute allowed intervals and sample a value.
- allowed = get_allowed_intervals(neighbor_values, delta)
- if not allowed:
- return None
- return sample_from_allowed(allowed)
-
-def generate_valid_color(neighbors, r):
- """
- Generate a random color that satisfies, for each neighbor,
- |R - R_neighbor| >= sqrt(r), and similarly for G and B.
- neighbors: list of neighbor colors (each is an array of shape (3,))
- r: threshold for squared difference.
- """
- delta = np.sqrt(r)
- color = np.zeros(3)
- for channel in range(3):
- neighbor_channel = [n[channel] for n in neighbors]
- sampled = sample_channel(neighbor_channel, delta)
- # If the allowed intervals are empty, use a fallback.
- if sampled is None:
- sampled = np.random.rand()
- color[channel] = sampled
- return color
-
-def generate_color_pattern(h, w, d, r):
- """
- Generate a dense color pattern.
- h, w: height and width of the output image in pixels.
- d: patch size in pixels (each patch is d x d).
- r: randomness regulation factor. For each neighbor the condition is:
- (|channel_new - channel_neighbor|)^2 >= r.
- """
- pattern = np.zeros((h, w, 3), dtype=np.float32)
- num_patches_x = h // d
- num_patches_y = w // d
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Collect neighbor patch colors (using mean because patches have uniform color).
- neighbors = []
- if i > 0:
- patch_above = pattern[(i-1)*d:i*d, j*d:(j+1)*d]
- neighbors.append(np.mean(patch_above, axis=(0, 1)))
- if j > 0:
- patch_left = pattern[i*d:(i+1)*d, (j-1)*d:j*d]
- neighbors.append(np.mean(patch_left, axis=(0, 1)))
-
- if len(neighbors) == 0:
- # For the first patch use a random color.
- new_color = np.random.rand(3)
- else:
- new_color = generate_valid_color(neighbors, r)
-
- pattern[i*d:(i+1)*d, j*d:(j+1)*d] = new_color
- print('Patch ({}, {})'.format(i, j))
- return pattern
-
-def main():
- # Parameters for the pattern.
- # For example, using 100x100 pixels image with patches of 3x3 pixels.
- height = 1200
- width = 1000
- patch_size = 12
- # Set r to a value that gives sufficient difference.
- # Note: r is the threshold for squared difference per channel, and the condition is
- # |c - c_neighbor| >= sqrt(r). Very high r may make it impossible to satisfy.
- r = 1000
-
- pattern = generate_color_pattern(height, width, patch_size, r)
- # Scale to 0-255 for display and saving.
- pattern_display = (pattern * 255).astype(np.uint8)
-
- # Display the pattern with OpenCV.
- cv2.imshow('Pattern', pattern_display)
- cv2.waitKey(0)
- cv2.destroyAllWindows()
-
- # Save the pattern as an image file.
- cv2.imwrite('color_pattern.png', pattern_display)
-
-if __name__ == '__main__':
- main()
diff --git a/tools/patch_generation/patchgen_DD02M.py b/tools/patch_generation/patchgen_DD02M.py
deleted file mode 100644
index 9b869ec..0000000
--- a/tools/patch_generation/patchgen_DD02M.py
+++ /dev/null
@@ -1,141 +0,0 @@
-#%%
-import numpy as np
-import cv2
-
-def merge_intervals(intervals):
- # Merge overlapping intervals.
- if not intervals:
- return []
- intervals.sort(key=lambda x: x[0])
- merged = [intervals[0]]
- for current in intervals[1:]:
- last = merged[-1]
- if current[0] <= last[1]:
- merged[-1] = (last[0], max(last[1], current[1]))
- else:
- merged.append(current)
- return merged
-
-def get_allowed_intervals(neighbor_values, delta):
- # Compute forbidden intervals for this channel from neighbor values.
- forbidden = []
- for c in neighbor_values:
- low = max(0, c - delta)
- high = min(1, c + delta)
- forbidden.append((low, high))
- # Merge overlapping forbidden intervals.
- merged = merge_intervals(forbidden)
- allowed = []
- # Allowed region before the first forbidden interval.
- if merged:
- if merged[0][0] > 0:
- allowed.append((0, merged[0][0]))
- # Allowed regions between forbidden intervals.
- for i in range(1, len(merged)):
- allowed.append((merged[i-1][1], merged[i][0]))
- # Allowed region after the last forbidden interval.
- if merged[-1][1] < 1:
- allowed.append((merged[-1][1], 1))
- else:
- allowed.append((0, 1))
- return allowed
-
-def sample_from_allowed(allowed):
- # Choose a random value from the allowed intervals with probability proportional to interval length.
- lengths = [end - start for start, end in allowed]
- total_length = sum(lengths)
- if total_length <= 0:
- return None
- point = np.random.rand() * total_length
- cumulative = 0
- for (start, end), length in zip(allowed, lengths):
- cumulative += length
- if point <= cumulative:
- return np.random.uniform(start, end)
- return allowed[-1][0]
-
-def sample_channel(neighbor_values, delta):
- # Compute allowed intervals and sample a value.
- allowed = get_allowed_intervals(neighbor_values, delta)
- if not allowed:
- return None
- return sample_from_allowed(allowed)
-
-def generate_valid_color(neighbors, r):
- """
- Generate a random color that satisfies, for each neighbor,
- |R - R_neighbor| >= sqrt(r), and similarly for G and B.
- neighbors: list of neighbor colors (each is an array of shape (3,))
- r: threshold for squared difference.
- """
- delta = np.sqrt(r)
- color = np.zeros(3)
- for channel in range(3):
- neighbor_channel = [n[channel] for n in neighbors]
- sampled = sample_channel(neighbor_channel, delta)
- # If the allowed intervals are empty, use a fallback.
- if sampled is None:
- sampled = np.random.rand()
- color[channel] = sampled
- return color
-
-def generate_color_pattern(h, w, d, r):
- """
- Generate a dense color pattern.
- h, w: height and width of the output image in pixels.
- d: patch size in pixels (each patch is d x d).
- r: randomness regulation factor. For each neighbor the condition is:
- (|channel_new - channel_neighbor|)^2 >= r.
- """
- pattern = np.zeros((h, w, 3), dtype=np.float32)
- num_patches_x = h // d
- num_patches_y = w // d
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Collect neighbor patch colors (using mean because patches have uniform color).
- neighbors = []
- if i > 0:
- patch_above = pattern[(i-1)*d:i*d, j*d:(j+1)*d]
- neighbors.append(np.mean(patch_above, axis=(0, 1)))
- if j > 0:
- patch_left = pattern[i*d:(i+1)*d, (j-1)*d:j*d]
- neighbors.append(np.mean(patch_left, axis=(0, 1)))
-
- if len(neighbors) == 0:
- # For the first patch use a random color.
- new_color = np.random.rand(3)
- else:
- new_color = generate_valid_color(neighbors, r)
-
- pattern[i*d:(i+1)*d, j*d:(j+1)*d] = new_color
- print('Patch ({}, {})'.format(i, j))
- return pattern
-
-def main():
- # Parameters for the pattern.
- # For example, using 100x100 pixels image with patches of 3x3 pixels.
- # height = 2750
- # width = 1950
- height = 1200
- width = 1000
- patch_size = 12
- # Set r to a value that gives sufficient difference.
- # Note: r is the threshold for squared difference per channel, and the condition is
- # |c - c_neighbor| >= sqrt(r). Very high r may make it impossible to satisfy.
- r = 1000
-
- pattern = generate_color_pattern(height, width, patch_size, r)
- # Scale to 0-255 for display and saving.
- pattern_display = (pattern * 255).astype(np.uint8)
-
- # Display the pattern with OpenCV.
- cv2.imshow('Pattern', pattern_display)
- cv2.waitKey(0)
- cv2.destroyAllWindows()
-
- # Save the pattern as an image file.
- cv2.imwrite('color_pattern.png', pattern_display)
-
-if __name__ == '__main__':
- main()
diff --git a/tools/patch_generation/patchgen_DD03.py b/tools/patch_generation/patchgen_DD03.py
deleted file mode 100644
index 27341d9..0000000
--- a/tools/patch_generation/patchgen_DD03.py
+++ /dev/null
@@ -1,141 +0,0 @@
-#%%
-import numpy as np
-import cv2
-
-def merge_intervals(intervals):
- # Merge overlapping intervals.
- if not intervals:
- return []
- intervals.sort(key=lambda x: x[0])
- merged = [intervals[0]]
- for current in intervals[1:]:
- last = merged[-1]
- if current[0] <= last[1]:
- merged[-1] = (last[0], max(last[1], current[1]))
- else:
- merged.append(current)
- return merged
-
-def get_allowed_intervals(neighbor_values, delta):
- # Compute forbidden intervals for this channel from neighbor values.
- forbidden = []
- for c in neighbor_values:
- low = max(0, c - delta)
- high = min(1, c + delta)
- forbidden.append((low, high))
- # Merge overlapping forbidden intervals.
- merged = merge_intervals(forbidden)
- allowed = []
- # Allowed region before the first forbidden interval.
- if merged:
- if merged[0][0] > 0:
- allowed.append((0, merged[0][0]))
- # Allowed regions between forbidden intervals.
- for i in range(1, len(merged)):
- allowed.append((merged[i-1][1], merged[i][0]))
- # Allowed region after the last forbidden interval.
- if merged[-1][1] < 1:
- allowed.append((merged[-1][1], 1))
- else:
- allowed.append((0, 1))
- return allowed
-
-def sample_from_allowed(allowed):
- # Choose a random value from the allowed intervals with probability proportional to interval length.
- lengths = [end - start for start, end in allowed]
- total_length = sum(lengths)
- if total_length <= 0:
- return None
- point = np.random.rand() * total_length
- cumulative = 0
- for (start, end), length in zip(allowed, lengths):
- cumulative += length
- if point <= cumulative:
- return np.random.uniform(start, end)
- return allowed[-1][0]
-
-def sample_channel(neighbor_values, delta):
- # Compute allowed intervals and sample a value.
- allowed = get_allowed_intervals(neighbor_values, delta)
- if not allowed:
- return None
- return sample_from_allowed(allowed)
-
-def generate_valid_color(neighbors, r):
- """
- Generate a random color that satisfies, for each neighbor,
- |R - R_neighbor| >= sqrt(r), and similarly for G and B.
- neighbors: list of neighbor colors (each is an array of shape (3,))
- r: threshold for squared difference.
- """
- delta = np.sqrt(r)
- color = np.zeros(3)
- for channel in range(3):
- neighbor_channel = [n[channel] for n in neighbors]
- sampled = sample_channel(neighbor_channel, delta)
- # If the allowed intervals are empty, use a fallback.
- if sampled is None:
- sampled = np.random.rand()
- color[channel] = sampled
- return color
-
-def generate_color_pattern(h, w, d, r):
- """
- Generate a dense color pattern.
- h, w: height and width of the output image in pixels.
- d: patch size in pixels (each patch is d x d).
- r: randomness regulation factor. For each neighbor the condition is:
- (|channel_new - channel_neighbor|)^2 >= r.
- """
- pattern = np.zeros((h, w, 3), dtype=np.float32)
- num_patches_x = h // d
- num_patches_y = w // d
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Collect neighbor patch colors (using mean because patches have uniform color).
- neighbors = []
- if i > 0:
- patch_above = pattern[(i-1)*d:i*d, j*d:(j+1)*d]
- neighbors.append(np.mean(patch_above, axis=(0, 1)))
- if j > 0:
- patch_left = pattern[i*d:(i+1)*d, (j-1)*d:j*d]
- neighbors.append(np.mean(patch_left, axis=(0, 1)))
-
- if len(neighbors) == 0:
- # For the first patch use a random color.
- new_color = np.random.rand(3)
- else:
- new_color = generate_valid_color(neighbors, r)
-
- pattern[i*d:(i+1)*d, j*d:(j+1)*d] = new_color
- print('Patch ({}, {})'.format(i, j))
- return pattern
-
-def main():
- # Parameters for the pattern.
- # For example, using 100x100 pixels image with patches of 3x3 pixels.
- # height = 2750
- # width = 1950
- height = 1340
- width = 1360
- patch_size = 12
- # Set r to a value that gives sufficient difference.
- # Note: r is the threshold for squared difference per channel, and the condition is
- # |c - c_neighbor| >= sqrt(r). Very high r may make it impossible to satisfy.
- r = 1000
-
- pattern = generate_color_pattern(height, width, patch_size, r)
- # Scale to 0-255 for display and saving.
- pattern_display = (pattern * 255).astype(np.uint8)
-
- # Display the pattern with OpenCV.
- cv2.imshow('Pattern', pattern_display)
- cv2.waitKey(0)
- cv2.destroyAllWindows()
-
- # Save the pattern as an image file.
- cv2.imwrite('color_pattern.png', pattern_display)
-
-if __name__ == '__main__':
- main()
diff --git a/tools/patch_generation/patchgen_DD03_gray.py b/tools/patch_generation/patchgen_DD03_gray.py
deleted file mode 100644
index 5ae8290..0000000
--- a/tools/patch_generation/patchgen_DD03_gray.py
+++ /dev/null
@@ -1,233 +0,0 @@
-#%%
-import numpy as np
-import cv2
-
-def merge_intervals(intervals):
- # Merge overlapping intervals.
- if not intervals:
- return []
- intervals.sort(key=lambda x: x[0])
- merged = [intervals[0]]
- for current in intervals[1:]:
- last = merged[-1]
- if current[0] <= last[1]:
- merged[-1] = (last[0], max(last[1], current[1]))
- else:
- merged.append(current)
- return merged
-
-def get_allowed_intervals(neighbor_values, delta):
- # Compute forbidden intervals for this channel from neighbor values.
- forbidden = []
- for c in neighbor_values:
- low = max(0, c - delta)
- high = min(1, c + delta)
- forbidden.append((low, high))
- # Merge overlapping forbidden intervals.
- merged = merge_intervals(forbidden)
- allowed = []
- # Allowed region before the first forbidden interval.
- if merged:
- if merged[0][0] > 0:
- allowed.append((0, merged[0][0]))
- # Allowed regions between forbidden intervals.
- for i in range(1, len(merged)):
- allowed.append((merged[i-1][1], merged[i][0]))
- # Allowed region after the last forbidden interval.
- if merged[-1][1] < 1:
- allowed.append((merged[-1][1], 1))
- else:
- allowed.append((0, 1))
- return allowed
-
-def sample_from_allowed(allowed):
- # Choose a random value from the allowed intervals with probability proportional to interval length.
- lengths = [end - start for start, end in allowed]
- total_length = sum(lengths)
- if total_length <= 0:
- return None
- point = np.random.rand() * total_length
- cumulative = 0
- for (start, end), length in zip(allowed, lengths):
- cumulative += length
- if point <= cumulative:
- return np.random.uniform(start, end)
- return allowed[-1][0]
-
-def sample_channel(neighbor_values, delta):
- # Compute allowed intervals and sample a value.
- allowed = get_allowed_intervals(neighbor_values, delta)
- if not allowed:
- return None
- return sample_from_allowed(allowed)
-
-def clip_allowed_to_min(allowed, min_value):
- clipped = []
- for start, end in allowed:
- start = max(start, min_value)
- if end > start:
- clipped.append((start, end))
- return clipped
-
-
-def generate_valid_gray(neighbor_grays, r, black_threshold):
- delta = np.sqrt(r)
- min_gray = min_gray_normalized(black_threshold)
- allowed = clip_allowed_to_min(get_allowed_intervals(neighbor_grays, delta), min_gray)
- if not allowed:
- return np.random.uniform(min_gray, 1.0)
- sampled = sample_from_allowed(allowed)
- if sampled is None:
- return np.random.uniform(min_gray, 1.0)
- return enforce_min_gray(sampled, black_threshold)
-
-
-def rgb_to_gray(color):
- # ITU-R BT.601 luma, color channels in [0, 1].
- r, g, b = color
- return 0.299 * r + 0.587 * g + 0.114 * b
-
-
-def min_gray_normalized(black_threshold):
- # Strictly above black_threshold in uint8 space.
- return (float(black_threshold) + 1.0) / 255.0
-
-
-def gray_to_rgb(gray):
- gray = float(gray)
- return np.array([gray, gray, gray], dtype=np.float32)
-
-
-def enforce_min_gray(gray, black_threshold):
- return max(float(gray), min_gray_normalized(black_threshold))
-
-
-def generate_valid_color(neighbors, r):
- """
- Generate a random color that satisfies, for each neighbor,
- |R - R_neighbor| >= sqrt(r), and similarly for G and B.
- neighbors: list of neighbor colors (each is an array of shape (3,))
- r: threshold for squared difference.
- """
- delta = np.sqrt(r)
- color = np.zeros(3)
- for channel in range(3):
- neighbor_channel = [n[channel] for n in neighbors]
- sampled = sample_channel(neighbor_channel, delta)
- # If the allowed intervals are empty, use a fallback.
- if sampled is None:
- sampled = np.random.rand()
- color[channel] = sampled
- return color
-
-def fill_pattern_remainder(pattern, h, w, d, black_threshold):
- """Fill pixels not covered by full dxd patches (image size may not be divisible by d)."""
- min_gray = min_gray_normalized(black_threshold)
- filled_h = (h // d) * d
- filled_w = (w // d) * d
-
- if filled_h < h and filled_w > 0:
- remainder_h = h - filled_h
- source = pattern[filled_h - d:filled_h, :filled_w]
- pattern[filled_h:h, :filled_w] = np.tile(
- source, ((remainder_h + d - 1) // d, 1)
- )[:remainder_h, :]
-
- if filled_w < w and filled_h > 0:
- remainder_w = w - filled_w
- source = pattern[:filled_h, filled_w - d:filled_w]
- pattern[:filled_h, filled_w:w] = np.tile(source, (1, (remainder_w + d - 1) // d))[:, :remainder_w]
-
- if filled_h < h and filled_w < w:
- remainder_h = h - filled_h
- remainder_w = w - filled_w
- source = pattern[filled_h - d:filled_h, filled_w - d:filled_w]
- pattern[filled_h:h, filled_w:w] = np.tile(
- source,
- ((remainder_h + d - 1) // d, (remainder_w + d - 1) // d),
- )[:remainder_h, :remainder_w]
-
- pattern[:] = np.maximum(pattern, min_gray)
- return pattern
-
-
-def generate_color_pattern(h, w, d, r, black_threshold):
- """
- Generate a dense grayscale pattern.
- h, w: height and width of the output image in pixels.
- d: patch size in pixels (each patch is d x d).
- r: randomness regulation factor. For each neighbor the condition is:
- (|channel_new - channel_neighbor|)^2 >= r.
- black_threshold: minimum grayscale value in [0, 255]; patches cannot be black.
- """
- pattern = np.zeros((h, w), dtype=np.float32)
- num_patches_x = h // d
- num_patches_y = w // d
- min_gray = min_gray_normalized(black_threshold)
-
- for i in range(num_patches_x):
- for j in range(num_patches_y):
- # Collect neighbor patch grays as RGB triples for the existing sampler.
- neighbors = []
- neighbor_grays = []
- if i > 0:
- patch_above = pattern[(i-1)*d:i*d, j*d:(j+1)*d]
- gray_above = float(np.mean(patch_above))
- neighbor_grays.append(gray_above)
- neighbors.append(gray_to_rgb(gray_above))
- if j > 0:
- patch_left = pattern[i*d:(i+1)*d, (j-1)*d:j*d]
- gray_left = float(np.mean(patch_left))
- neighbor_grays.append(gray_left)
- neighbors.append(gray_to_rgb(gray_left))
-
- if len(neighbors) == 0:
- # First patch: sample directly above the black threshold.
- gray = np.random.uniform(min_gray, 1.0)
- else:
- new_color = generate_valid_color(neighbors, r)
- gray = rgb_to_gray(new_color)
- if gray < min_gray:
- gray = generate_valid_gray(neighbor_grays, r, black_threshold)
- else:
- gray = enforce_min_gray(gray, black_threshold)
-
- pattern[i*d:(i+1)*d, j*d:(j+1)*d] = gray
- print('Patch ({}, {}) gray={:.3f}'.format(i, j, gray))
-
- return fill_pattern_remainder(pattern, h, w, d, black_threshold)
-
-def main():
- # Parameters for the pattern.
- # For example, using 100x100 pixels image with patches of 3x3 pixels.
- # height = 2750
- # width = 1950
- height = 1340
- width = 1360
- patch_size = 12
- black_threshold = 125
- # Set r to a value that gives sufficient difference.
- # Note: r is the threshold for squared difference per channel, and the condition is
- # |c - c_neighbor| >= sqrt(r). Very high r may make it impossible to satisfy.
- r = 1000
-
- pattern = generate_color_pattern(height, width, patch_size, r, black_threshold)
- # Scale to 0-255 for display and saving.
- pattern_display = (pattern * 255).astype(np.uint8)
-
- if pattern_display.min() <= black_threshold:
- raise RuntimeError(
- f"Generated pattern contains values <= black_threshold ({black_threshold}); "
- f"min={int(pattern_display.min())}"
- )
-
- # Display the pattern with OpenCV.
- cv2.imshow('Pattern', pattern_display)
- cv2.waitKey(0)
- cv2.destroyAllWindows()
-
- # Save the pattern as an image file.
- cv2.imwrite('gray_pattern.png', pattern_display)
-
-if __name__ == '__main__':
- main()
diff --git a/tools/patch_generation/w19.5_l27.5_p12.pdf b/tools/patch_generation/w19.5_l27.5_p12.pdf
deleted file mode 100644
index 8a1bf4d..0000000
Binary files a/tools/patch_generation/w19.5_l27.5_p12.pdf and /dev/null differ
diff --git a/tools/patch_generation/w19.5_l27.5_p12_checker.pdf b/tools/patch_generation/w19.5_l27.5_p12_checker.pdf
deleted file mode 100644
index bb22801..0000000
Binary files a/tools/patch_generation/w19.5_l27.5_p12_checker.pdf and /dev/null differ
diff --git a/tools/patch_generation/彩色+黑白-svg-pdf.zip b/tools/patch_generation/彩色+黑白-svg-pdf.zip
deleted file mode 100644
index 8d68d23..0000000
Binary files a/tools/patch_generation/彩色+黑白-svg-pdf.zip and /dev/null differ