mirror of
https://github.com/barkeser2002/VMD-Motion-Optimizer.git
synced 2026-09-25 04:30:15 +03:00
Kamera/ışık/gölge/IK blokları korunuyor: okuyucu morph bloğundan sonrasını ayrıştırıp ham olarak saklıyor, yazıcı da aynen geri yazıyor. Önceden yalnızca iki adet 0 yazılıyordu; bir kamera VMD'si 62 baytlık kütüğe indirgeniyor ve "Kaydedildi" deniyordu. İnterpolasyon (bezier) eğrileri korunuyor: her hayatta kalan kare kendi 64 baytlık bloğunu taşıyor. Önceden hepsi sıfırlanıyordu, yani eps=0'da bile elle ayarlanmış yumuşatmalar siliniyordu. --flatten-interp ile eski davranış istenirse seçilebilir. _moving_average artık girdiyle aynı uzunlukta dönüyor. Önceden n+window-1 eleman dönüyordu; frame dizisi ile değer dizisi kayıyor ve her ofset yanlış slottan okunuyordu (IndexError vermediği için sessizce). remove_depth_alignment yeniden yazıldı: kökün Z eğilimi ölçülüp çıkarılıyor. Önceden kökün Z'si kendinden çıkarılıyordu; varsayılan bayraklarla TÜM ileri/ geri hareket tam olarak 0.0 oluyordu. Artık --depth-smooth >= 2 zorunlu. stabilize_ground yeniden yazıldı: exclude_ik gerçekten uygulanıyor (önceden ik_candidates atanıp hiç kullanılmıyor, log ise exclude_ik=True yazıyordu). Çıplak '足' eşleşmesi kaldırıldı - 左足IK, 左足先EX, 左足D gibi tüm bacak zincirini yakalıyordu. IK dışlanınca ölçülebilir veri kalmazsa geri alınıyor. Morph sabit aralıklarının SON karesi de korunuyor. Önceden yalnızca ilki tutuluyordu; 0-100 arası açık kalan bir göz, 105'teki kırpmaya kadar yavaşça kapanan bir rampaya dönüyordu. Quaternionlar açısal hata ölçümünden önce normalize ediliyor. |q| > 1 olan dosyalarda dot > 1 oluyor, acos 1.0'a kırpılıyor, her örnek için hata 0 görünüyor ve tüm rotasyon kanalı iki keyframe'e çöküyordu. RDP özyineleme yerine açık yığın kullanıyor. Sönümlü salınımlı kanallarda (yerleşen bir pose-estimation kemiği) derinlik O(n) olup RecursionError veriyordu - 1500 anahtarlık zikzakta doğrulandı. VMD 1.0 başlığı (10 baytlık model adı) doğru ofsetlerle okunuyor. İmza kabul ediliyor ama v2 ofsetleriyle ayrıştırılıyordu, her şey 10 bayt kayıyordu. 5000 adımlık kemik-sayısı taraması kaldırıldı: 10 MB'lık bir dosyada ~450M numpy ayırmalı yineleme (saatler) demekti ve tek geçerlilik ölçütü "cam/lig <= 100000" olduğu için hizasız çöp ayrıştırmalarını kabul ediyordu. Yerine ucuz ve ilkeli bir başlık düzeni taraması + dosyayı tam tüketme şartı.
1157 lines
41 KiB
Python
1157 lines
41 KiB
Python
# VMD Motion Optimizer by Barış Keser (barkeser2002)
|
||
# License: GNU General Public License v3.0 (GPL-3.0)
|
||
# See LICENSE for details.
|
||
|
||
import argparse
|
||
import math
|
||
import unicodedata
|
||
from collections import defaultdict
|
||
from dataclasses import dataclass
|
||
from typing import List, Tuple, Dict, Callable, Optional
|
||
|
||
import numpy as np
|
||
from tqdm import tqdm
|
||
|
||
import os
|
||
import tempfile
|
||
import struct
|
||
import sys
|
||
|
||
# ---------- Minimal VMD IO (v1 + v2) ----------
|
||
|
||
CP932 = 'cp932'
|
||
|
||
SIG_V2 = b"Vocaloid Motion Data 0002"
|
||
SIG_V1 = b"Vocaloid Motion Data file"
|
||
|
||
# Sabit blok boyutları (VMD spesifikasyonu)
|
||
SIG_SIZE = 30
|
||
BONE_NAME_SIZE = 15
|
||
MORPH_NAME_SIZE = 15
|
||
BONE_FRAME_SIZE = 15 + 4 + 7 * 4 + 64 # 111
|
||
MORPH_FRAME_SIZE = 15 + 4 + 4 # 23
|
||
CAMERA_FRAME_SIZE = 61
|
||
LIGHT_FRAME_SIZE = 28
|
||
SHADOW_FRAME_SIZE = 9
|
||
IK_BONE_ENTRY_SIZE = 20 + 1
|
||
|
||
# MMD'nin varsayılan interpolasyon (bezier) tablosu: kanal başına (20,20,107,107).
|
||
# Sadece ara değerden üretilmiş kareler için kullanılır; orijinal kareler kendi
|
||
# eğrilerini korur.
|
||
MMD_DEFAULT_INTERP = bytes([
|
||
0x14, 0x14, 0x00, 0x00, 0x14, 0x14, 0x14, 0x14,
|
||
0x6b, 0x6b, 0x6b, 0x6b, 0x6b, 0x6b, 0x6b, 0x6b,
|
||
0x14, 0x14, 0x00, 0x00, 0x14, 0x14, 0x14, 0x6b,
|
||
0x6b, 0x6b, 0x6b, 0x6b, 0x6b, 0x6b, 0x6b, 0x00,
|
||
0x14, 0x00, 0x00, 0x14, 0x14, 0x14, 0x6b, 0x6b,
|
||
0x6b, 0x6b, 0x6b, 0x6b, 0x6b, 0x6b, 0x00, 0x00,
|
||
0x00, 0x00, 0x14, 0x14, 0x14, 0x6b, 0x6b, 0x6b,
|
||
0x6b, 0x6b, 0x6b, 0x6b, 0x6b, 0x00, 0x00, 0x00,
|
||
])
|
||
|
||
# cp932'de karşılığı olmayan Latin harfleri için çevriyazı tablosu.
|
||
# ('Barış Keser' -> 'Baris Keser'; errors='ignore' bunu 'Bar Keser' yapıyordu.)
|
||
_TRANSLIT = str.maketrans({
|
||
'ı': 'i', 'İ': 'I', 'ş': 's', 'Ş': 'S', 'ğ': 'g', 'Ğ': 'G',
|
||
'ç': 'c', 'Ç': 'C', 'ö': 'o', 'Ö': 'O', 'ü': 'u', 'Ü': 'U',
|
||
'â': 'a', 'Â': 'A', 'î': 'i', 'Î': 'I', 'û': 'u', 'Û': 'U',
|
||
})
|
||
|
||
|
||
class CancelledError(Exception):
|
||
"""Kullanıcı işlemi iptal ettiğinde yükseltilir."""
|
||
|
||
|
||
def _encode_cp932(name: str) -> bytes:
|
||
"""cp932'ye kodla; kodlanamayan karakterleri sessizce SİLME, çevriyazı yap."""
|
||
try:
|
||
return name.encode(CP932)
|
||
except UnicodeEncodeError:
|
||
pass
|
||
out = bytearray()
|
||
for ch in name.translate(_TRANSLIT):
|
||
try:
|
||
out += ch.encode(CP932)
|
||
continue
|
||
except UnicodeEncodeError:
|
||
pass
|
||
folded = unicodedata.normalize('NFKD', ch).encode('ascii', 'ignore')
|
||
out += folded if folded else b'?'
|
||
return bytes(out)
|
||
|
||
|
||
def _truncate_cp932(data: bytes, limit: int) -> bytes:
|
||
"""Çok baytlı bir karakterin ortasından kesmeden `limit` bayta kırp."""
|
||
if len(data) <= limit:
|
||
return data
|
||
cut = data[:limit]
|
||
while cut:
|
||
try:
|
||
cut.decode(CP932)
|
||
break
|
||
except UnicodeDecodeError:
|
||
cut = cut[:-1]
|
||
return cut
|
||
|
||
|
||
def _decode_name(raw: bytes) -> str:
|
||
if b'\x00' in raw:
|
||
raw = raw.split(b'\x00', 1)[0]
|
||
try:
|
||
return raw.decode(CP932, errors='ignore')
|
||
except Exception:
|
||
return raw.decode('latin1', errors='ignore')
|
||
|
||
|
||
def _write_name(f, name: str, size: int):
|
||
data = _truncate_cp932(_encode_cp932(name or ''), size)
|
||
f.write(data + b'\x00' * (size - len(data)))
|
||
|
||
|
||
@dataclass
|
||
class BoneFrame:
|
||
name: str
|
||
frame: int
|
||
pos: np.ndarray # shape (3,)
|
||
quat: np.ndarray # shape (4,)
|
||
interp: bytes | None = None # 64 bytes
|
||
|
||
|
||
@dataclass
|
||
class MorphFrame:
|
||
name: str
|
||
frame: int
|
||
weight: float
|
||
|
||
|
||
@dataclass
|
||
class Motion:
|
||
model_name: str
|
||
bones: List[BoneFrame]
|
||
morphs: List[MorphFrame]
|
||
# Morph bloğundan sonraki ham veri: kamera, ışık, self-shadow ve IK/görünürlük
|
||
# blokları. Optimize edilmez, olduğu gibi korunur.
|
||
trailing: bytes = b''
|
||
version: int = 2
|
||
|
||
|
||
# Yardımcılar: seri yumuşatma ve derinlik (Z) hizası kaldırma
|
||
|
||
def _moving_average(values: List[float], window: int) -> List[float]:
|
||
"""Ortalanmış hareketli ortalama. Çıktı uzunluğu HER ZAMAN girdiyle aynıdır."""
|
||
if not window or window <= 1 or len(values) == 0:
|
||
return list(values)
|
||
n = len(values)
|
||
w = min(int(window), n)
|
||
arr = np.asarray(values, dtype=np.float64)
|
||
pad_l = (w - 1) // 2
|
||
pad_r = (w - 1) - pad_l
|
||
padded = np.pad(arr, (pad_l, pad_r), mode='edge')
|
||
csum = np.cumsum(np.insert(padded, 0, 0.0))
|
||
out = (csum[w:] - csum[:-w]) / float(w)
|
||
return [float(x) for x in out]
|
||
|
||
|
||
def _series_at(frame_arr: np.ndarray, value_arr: np.ndarray, f: int) -> float:
|
||
"""frame_arr üzerinde f için tam eşleşme veya lineer interpolasyon."""
|
||
idx = int(np.searchsorted(frame_arr, f))
|
||
if idx < len(frame_arr) and frame_arr[idx] == f:
|
||
return float(value_arr[idx])
|
||
i1, i2 = idx - 1, idx
|
||
if i1 < 0:
|
||
return float(value_arr[0])
|
||
if i2 >= len(frame_arr):
|
||
return float(value_arr[-1])
|
||
f1, f2 = float(frame_arr[i1]), float(frame_arr[i2])
|
||
if f2 == f1:
|
||
return float(value_arr[i1])
|
||
t = (f - f1) / (f2 - f1)
|
||
return float(value_arr[i1] * (1 - t) + value_arr[i2] * t)
|
||
|
||
|
||
DEFAULT_ROOT_CANDIDATES = [
|
||
'全ての親', 'AllParent',
|
||
'センター', 'Center', 'センタ',
|
||
'グルーブ', 'Groove',
|
||
'Root', 'root',
|
||
]
|
||
|
||
# Ayak/ayak bileği kemikleri. Çıplak '足' KULLANILMAZ - o, 左足IK / 左足先EX /
|
||
# 左足D gibi tüm bacak zincirini de yakalıyordu.
|
||
DEFAULT_FEET_NAMES = [
|
||
'左足', '右足', '左足首', '右足首',
|
||
'左足IK', '右足IK', '左足IK', '右足IK',
|
||
'左つま先IK', '右つま先IK', '左つま先IK', '右つま先IK',
|
||
'つま先', 'つま先IK', 'つま先IK',
|
||
'LeftFoot', 'RightFoot', 'LeftAnkle', 'RightAnkle',
|
||
'LeftToe', 'RightToe', 'Foot', 'Ankle', 'Toe', 'ToeIK',
|
||
]
|
||
|
||
DEFAULT_IK_MARKERS = ['IK', 'IK', 'Ik', 'IK親', 'IK親']
|
||
|
||
|
||
def _is_ik_bone(name: str, ik_markers: List[str]) -> bool:
|
||
return any(marker in name for marker in ik_markers)
|
||
|
||
|
||
def _pick_root(names, root_candidates: List[str]) -> Optional[str]:
|
||
for cand in root_candidates:
|
||
if cand in names:
|
||
return cand
|
||
return None
|
||
|
||
|
||
def remove_depth_alignment(motion: Motion,
|
||
root_candidates: Optional[List[str]] = None,
|
||
smooth_window: int = 0,
|
||
scale: float = 1.0,
|
||
log: Optional[Callable[[str], None]] = None) -> None:
|
||
"""
|
||
Kök/merkez kemiğin Z ekseninde biriken YAVAŞ KAYMAYI (drift) kaldırır.
|
||
|
||
Yöntem: kök Z serisinin düşük frekanslı eğilimi (hareketli ortalama) ölçülür ve
|
||
bu eğilimin ilk kareye göre farkı çıkarılır. Böylece karakterin gerçek ileri/geri
|
||
hareketi korunur, yalnızca derinlik kestiriminden gelen sürüklenme silinir.
|
||
|
||
NOT: smooth_window (>=2) ZORUNLUDUR. Eğilim penceresi olmadan "eğilim" sinyalin
|
||
kendisine eşit olur ve tüm Z hareketi sıfırlanırdı (v1.0.4'teki hata).
|
||
"""
|
||
if root_candidates is None:
|
||
root_candidates = list(DEFAULT_ROOT_CANDIDATES)
|
||
|
||
bone_names = set(b.name for b in motion.bones)
|
||
root_name = _pick_root(bone_names, root_candidates)
|
||
if root_name is None:
|
||
if log:
|
||
log("Depth: kök/merkez kemik bulunamadı; işlem atlandı")
|
||
return
|
||
|
||
if not smooth_window or smooth_window <= 1:
|
||
if log:
|
||
log("Depth: --depth-smooth >= 2 gerekli (eğilim penceresi yok); işlem atlandı. "
|
||
"Öneri: --depth-smooth 30")
|
||
return
|
||
|
||
frames: List[int] = []
|
||
zs: List[float] = []
|
||
for b in motion.bones:
|
||
if b.name == root_name:
|
||
frames.append(int(b.frame))
|
||
zs.append(float(b.pos[2]))
|
||
if len(frames) < 2:
|
||
if log:
|
||
log("Depth: kök kemikte yeterli key yok; işlem atlandı")
|
||
return
|
||
|
||
order = np.argsort(np.asarray(frames), kind='stable')
|
||
frames = [frames[i] for i in order]
|
||
zs = [zs[i] for i in order]
|
||
|
||
trend = _moving_average(zs, smooth_window)
|
||
baseline = trend[0]
|
||
|
||
frame_arr = np.asarray(frames, dtype=np.int64)
|
||
trend_arr = np.asarray(trend, dtype=np.float64)
|
||
|
||
if log:
|
||
log(f"Depth: root='{root_name}', keyler={len(frames)}, "
|
||
f"pencere={smooth_window}, scale={scale}")
|
||
|
||
moved = 0.0
|
||
for b in motion.bones:
|
||
if b.name != root_name:
|
||
continue
|
||
drift = (_series_at(frame_arr, trend_arr, int(b.frame)) - baseline) * scale
|
||
b.pos[2] = np.float32(float(b.pos[2]) - drift)
|
||
moved = max(moved, abs(drift))
|
||
if log:
|
||
log(f"Depth: en büyük düzeltme={moved:.4f}")
|
||
|
||
|
||
def stabilize_ground(motion: Motion,
|
||
target_y: float = 0.0,
|
||
use_feet_only: bool = True,
|
||
feet_candidates: Optional[List[str]] = None,
|
||
smooth_window: int = 0,
|
||
scale: float = 1.0,
|
||
root_candidates: Optional[List[str]] = None,
|
||
exclude_ik: bool = True,
|
||
ik_candidates: Optional[List[str]] = None,
|
||
log: Optional[Callable[[str], None]] = None) -> None:
|
||
"""
|
||
Ayak kemiklerinin en düşük Y ötelemesini ölçer ve bu değeri target_y'ye taşıyacak
|
||
ofseti kök/merkez kemiğe uygular.
|
||
|
||
MMD hiyerarşisinde ayak/IK kemikleri kökün altında olduğu için ofset yalnızca köke
|
||
uygulanır; tüm iskelet onunla birlikte taşınır.
|
||
|
||
exclude_ik=True ise IK kemikleri ÖLÇÜM kümesinden çıkarılır. Ölçüm kümesinde
|
||
anlamlı (sıfırdan farklı) Y verisi kalmazsa IK kemikleri geri alınır ve uyarı
|
||
verilir - aksi halde ölçüm sabit 0 olur ve stabilizasyon hiçbir şey yapmaz.
|
||
"""
|
||
if feet_candidates is None:
|
||
feet_candidates = list(DEFAULT_FEET_NAMES)
|
||
if ik_candidates is None:
|
||
ik_candidates = list(DEFAULT_IK_MARKERS)
|
||
if root_candidates is None:
|
||
root_candidates = list(DEFAULT_ROOT_CANDIDATES)
|
||
|
||
names = set(b.name for b in motion.bones)
|
||
|
||
root_name = _pick_root(names, root_candidates)
|
||
if root_name is None:
|
||
if log:
|
||
log("Ground: kök/merkez kemik bulunamadı; işlem atlandı")
|
||
return
|
||
|
||
def _select(with_ik: bool):
|
||
if use_feet_only:
|
||
sel = {n for n in names if n in feet_candidates}
|
||
if not sel:
|
||
# Tam eşleşme yoksa önek eşleşmesine düş (özel rig adlandırmaları)
|
||
sel = {n for n in names
|
||
for cand in feet_candidates
|
||
if len(cand) >= 2 and n.startswith(cand)}
|
||
else:
|
||
sel = set(names)
|
||
if not with_ik:
|
||
sel = {n for n in sel if not _is_ik_bone(n, ik_candidates)}
|
||
sel.discard(root_name)
|
||
return sel
|
||
|
||
selected = _select(with_ik=not exclude_ik)
|
||
|
||
def _measure(sel):
|
||
low: Dict[int, float] = {}
|
||
for b in motion.bones:
|
||
if b.name not in sel:
|
||
continue
|
||
f = int(b.frame)
|
||
y = float(b.pos[1])
|
||
if f not in low or y < low[f]:
|
||
low[f] = y
|
||
return low
|
||
|
||
minY = _measure(selected)
|
||
# IK hariç tutulduğunda ölçüm kümesi tamamen sıfırsa anlamlı bir zemin yoktur.
|
||
if exclude_ik and (not minY or all(abs(v) < 1e-9 for v in minY.values())):
|
||
fallback = _select(with_ik=True)
|
||
if fallback != selected:
|
||
if log:
|
||
log("Ground: IK hariç bırakıldığında ölçülebilir Y verisi kalmadı; "
|
||
"IK kemikleri ölçüme geri alındı")
|
||
selected = fallback
|
||
minY = _measure(selected)
|
||
|
||
if not selected or not minY:
|
||
if log:
|
||
log("Ground: ölçüm yapılacak kemik/key bulunamadı; işlem atlandı")
|
||
return
|
||
|
||
frames = sorted(minY.keys())
|
||
lows = [minY[f] for f in frames]
|
||
if smooth_window and smooth_window > 1:
|
||
lows = _moving_average(lows, smooth_window)
|
||
|
||
farr = np.asarray(frames, dtype=np.int64)
|
||
yarr = np.asarray(lows, dtype=np.float64)
|
||
|
||
if log:
|
||
log(f"Ground: root='{root_name}', ölçülen kemik={len(selected)}, "
|
||
f"frames={len(frames)}, target_y={target_y}, scale={scale}, "
|
||
f"exclude_ik={exclude_ik}")
|
||
|
||
moved = 0.0
|
||
for b in motion.bones:
|
||
if b.name != root_name:
|
||
continue
|
||
off = (_series_at(farr, yarr, int(b.frame)) - target_y) * scale
|
||
b.pos[1] = np.float32(float(b.pos[1]) - off)
|
||
moved = max(moved, abs(off))
|
||
if log:
|
||
log(f"Ground: en büyük düzeltme={moved:.4f}")
|
||
|
||
|
||
# ---------- Okuma ----------
|
||
|
||
def _parse_trailing(data: bytes, off: int) -> Optional[int]:
|
||
"""Morph bloğundan sonraki kamera/ışık/gölge/IK bloklarını doğrular.
|
||
|
||
Dosyanın herhangi bir blok sınırında bitmesine izin verilir (eski VMD'ler).
|
||
Geçerliyse bitiş ofsetini, aday düzen tutarsızsa None döndürür.
|
||
"""
|
||
total = len(data)
|
||
|
||
def u32(o: int) -> Optional[int]:
|
||
if o + 4 > total:
|
||
return None
|
||
return struct.unpack('<I', data[o:o + 4])[0]
|
||
|
||
for count_size in (CAMERA_FRAME_SIZE, LIGHT_FRAME_SIZE, SHADOW_FRAME_SIZE):
|
||
if off == total:
|
||
return off
|
||
n = u32(off)
|
||
if n is None:
|
||
return None
|
||
off += 4
|
||
if n > (total - off) // count_size:
|
||
return None
|
||
off += n * count_size
|
||
|
||
# IK / görünürlük blokları değişken uzunlukludur
|
||
if off == total:
|
||
return off
|
||
n = u32(off)
|
||
if n is None:
|
||
return None
|
||
off += 4
|
||
for _ in range(n):
|
||
if off + 9 > total:
|
||
return None
|
||
off += 4 # frame
|
||
off += 1 # görünürlük
|
||
k = u32(off)
|
||
if k is None:
|
||
return None
|
||
off += 4
|
||
if k > (total - off) // IK_BONE_ENTRY_SIZE:
|
||
return None
|
||
off += k * IK_BONE_ENTRY_SIZE
|
||
|
||
return off if off <= total else None
|
||
|
||
|
||
def _try_layout(data: bytes, name_off: int, name_len: int):
|
||
"""Verilen başlık düzeniyle dosyayı ayrıştırmayı dener.
|
||
|
||
Döndürür: (Motion, exact) veya None. `exact`, düzenin dosyayı tam olarak
|
||
tükettiğini gösterir - kabul için asıl ölçüt budur.
|
||
"""
|
||
total = len(data)
|
||
pos = name_off + name_len
|
||
if pos + 4 > total:
|
||
return None
|
||
|
||
model_name = _decode_name(data[name_off:name_off + name_len])
|
||
bone_count = struct.unpack('<I', data[pos:pos + 4])[0]
|
||
off = pos + 4
|
||
|
||
# Ucuz akıl sağlığı kontrolü - tam ayrıştırmadan önce elenebilenler
|
||
if bone_count > (total - off) // BONE_FRAME_SIZE:
|
||
return None
|
||
|
||
bones: List[BoneFrame] = []
|
||
for _ in range(bone_count):
|
||
name = _decode_name(data[off:off + BONE_NAME_SIZE])
|
||
o = off + BONE_NAME_SIZE
|
||
frame = struct.unpack('<I', data[o:o + 4])[0]
|
||
o += 4
|
||
px, py, pz, qx, qy, qz, qw = struct.unpack('<7f', data[o:o + 28])
|
||
o += 28
|
||
interp = data[o:o + 64]
|
||
off = o + 64
|
||
bones.append(BoneFrame(
|
||
name=name, frame=frame,
|
||
pos=np.array([px, py, pz], dtype=np.float32),
|
||
quat=np.array([qx, qy, qz, qw], dtype=np.float32),
|
||
interp=interp,
|
||
))
|
||
|
||
if off + 4 > total:
|
||
return None
|
||
morph_count = struct.unpack('<I', data[off:off + 4])[0]
|
||
off += 4
|
||
if morph_count > (total - off) // MORPH_FRAME_SIZE:
|
||
return None
|
||
|
||
morphs: List[MorphFrame] = []
|
||
for _ in range(morph_count):
|
||
name = _decode_name(data[off:off + MORPH_NAME_SIZE])
|
||
o = off + MORPH_NAME_SIZE
|
||
frame = struct.unpack('<I', data[o:o + 4])[0]
|
||
o += 4
|
||
(w,) = struct.unpack('<f', data[o:o + 4])
|
||
off = o + 4
|
||
morphs.append(MorphFrame(name=name, frame=frame, weight=float(w)))
|
||
|
||
end = _parse_trailing(data, off)
|
||
if end is None:
|
||
return None
|
||
|
||
motion = Motion(
|
||
model_name=model_name,
|
||
bones=bones,
|
||
morphs=morphs,
|
||
trailing=data[off:end],
|
||
version=1 if name_len == 10 else 2,
|
||
)
|
||
return motion, (end == total)
|
||
|
||
|
||
def read_vmd(path: str, log: Optional[Callable[[str], None]] = None) -> Motion | None:
|
||
with open(path, 'rb') as f:
|
||
data = f.read()
|
||
|
||
if data.startswith(SIG_V2):
|
||
candidates = [(SIG_SIZE, 20), (SIG_SIZE, 10)]
|
||
elif data.startswith(SIG_V1):
|
||
# VMD 1.0 model adı alanı 10 bayttır (v2'de 20)
|
||
candidates = [(SIG_SIZE, 10), (SIG_SIZE, 20)]
|
||
else:
|
||
if log:
|
||
log(f"Geçersiz VMD imzası: {data[:30]!r}")
|
||
return None
|
||
|
||
# Bozuk/dolgulu başlıklar için küçük ve sınırlı bir düzen taraması.
|
||
# (v1.0.4'teki 5000 adımlık kemik-sayısı taraması kaldırıldı: 10 MB'lık bir
|
||
# dosyada saatlerce sürebiliyor ve hizasız çöp ayrıştırmaları kabul ediyordu.)
|
||
for extra_off in range(25, 41):
|
||
for nl in (20, 10):
|
||
if (extra_off, nl) not in candidates:
|
||
candidates.append((extra_off, nl))
|
||
|
||
fallback = None
|
||
for name_off, name_len in candidates:
|
||
result = _try_layout(data, name_off, name_len)
|
||
if result is None:
|
||
continue
|
||
motion, exact = result
|
||
if exact:
|
||
return motion
|
||
if fallback is None:
|
||
fallback = motion
|
||
|
||
if fallback is not None:
|
||
if log:
|
||
log("Uyarı: dosya sonunda beklenmeyen fazladan veri var; "
|
||
"bilinen bloklar okundu")
|
||
return fallback
|
||
|
||
if log:
|
||
log("VMD düzeni çözülemedi (dosya bozuk veya kesilmiş olabilir)")
|
||
return None
|
||
|
||
|
||
# ---------- Yazma ----------
|
||
|
||
def write_vmd(path: str, motion: Motion) -> bool:
|
||
"""VMD'yi atomik olarak yazar: önce geçici dosya, sonra os.replace.
|
||
|
||
Böylece yazma sırasında bir hata olursa hedefteki mevcut dosya bozulmaz.
|
||
"""
|
||
dest_dir = os.path.dirname(os.path.abspath(path)) or '.'
|
||
os.makedirs(dest_dir, exist_ok=True)
|
||
fd, tmp_path = tempfile.mkstemp(suffix='.vmd.part', dir=dest_dir)
|
||
try:
|
||
with os.fdopen(fd, 'wb') as f:
|
||
f.write(SIG_V2 + b'\x00' * (SIG_SIZE - len(SIG_V2)))
|
||
_write_name(f, motion.model_name or '', 20)
|
||
|
||
f.write(struct.pack('<I', len(motion.bones)))
|
||
for b in motion.bones:
|
||
_write_name(f, b.name or '', BONE_NAME_SIZE)
|
||
f.write(struct.pack('<I', int(b.frame)))
|
||
f.write(struct.pack('<7f',
|
||
float(b.pos[0]), float(b.pos[1]), float(b.pos[2]),
|
||
float(b.quat[0]), float(b.quat[1]),
|
||
float(b.quat[2]), float(b.quat[3])))
|
||
interp = b.interp or MMD_DEFAULT_INTERP
|
||
if len(interp) != 64:
|
||
interp = (interp + b'\x00' * 64)[:64]
|
||
f.write(interp)
|
||
|
||
f.write(struct.pack('<I', len(motion.morphs)))
|
||
for m in motion.morphs:
|
||
_write_name(f, m.name or '', MORPH_NAME_SIZE)
|
||
f.write(struct.pack('<I', int(m.frame)))
|
||
f.write(struct.pack('<f', float(m.weight)))
|
||
|
||
# Kamera / ışık / self-shadow / IK blokları: kaynaktan olduğu gibi taşı.
|
||
# (v1.0.4 yalnızca iki adet 0 yazıyordu; kamera VMD'leri 62 baytlık
|
||
# bir kütüğe indirgeniyordu.)
|
||
if motion.trailing:
|
||
f.write(motion.trailing)
|
||
else:
|
||
f.write(struct.pack('<IIII', 0, 0, 0, 0))
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
os.replace(tmp_path, path)
|
||
return True
|
||
except BaseException:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
|
||
|
||
def _maybe_fix_vmd_header(src_path: str) -> Optional[str]:
|
||
"""XR Animator vb. araçların imza ile model adı arasına eklediği fazladan null
|
||
baytları temizlenmiş geçici bir kopya üretir.
|
||
|
||
Standart VMD başlığı: 30 bayt imza alanı + 20 bayt model adı. Bazı dosyalarda
|
||
imza (25 bayt) ile ad arasında beklenenden farklı dolgu bulunur.
|
||
|
||
Düzeltme gerekmiyorsa None döndürür (çağıran geçici dosya silmez).
|
||
"""
|
||
with open(src_path, 'rb') as f:
|
||
head = f.read(256)
|
||
if not head.startswith(SIG_V2):
|
||
return None
|
||
|
||
i = len(SIG_V2) # 25
|
||
j = i
|
||
while j < len(head) and head[j] == 0:
|
||
j += 1
|
||
|
||
# j == 30 -> standart düzen (5 bayt dolgu), dokunma.
|
||
# j < 30 -> ad, imza dolgu alanının içinde erken başlıyor; bu, düzeltmemiz
|
||
# gereken bozuk düzendir (j == 25 dahil: hiç dolgu yok).
|
||
# j > 30 -> ad alanı null ile başlıyor (boş model adı), standart.
|
||
if j >= SIG_SIZE:
|
||
return None
|
||
|
||
name_bytes = (head[j:j + 20] + b'\x00' * 20)[:20]
|
||
with open(src_path, 'rb') as f:
|
||
f.seek(j + 20)
|
||
rest = f.read()
|
||
|
||
fd, tmp_path = tempfile.mkstemp(suffix='.fixed.vmd')
|
||
with os.fdopen(fd, 'wb') as f:
|
||
f.write(SIG_V2 + b'\x00' * (SIG_SIZE - len(SIG_V2)))
|
||
f.write(name_bytes)
|
||
f.write(rest)
|
||
return tmp_path
|
||
|
||
|
||
# ---------- Yardimci matematik ----------
|
||
|
||
def quat_normalize(q: np.ndarray) -> np.ndarray:
|
||
n = np.linalg.norm(q)
|
||
if n == 0:
|
||
return np.array([0, 0, 0, 1], dtype=np.float32)
|
||
return (q / n).astype(np.float32)
|
||
|
||
|
||
def quat_dot(a: np.ndarray, b: np.ndarray) -> float:
|
||
return float(np.dot(a, b))
|
||
|
||
|
||
@dataclass
|
||
class BoneKey:
|
||
frame: int
|
||
loc: Tuple[float, float, float]
|
||
rot: Tuple[float, float, float, float]
|
||
interp: bytes | None = None
|
||
|
||
|
||
@dataclass
|
||
class MorphKey:
|
||
frame: int
|
||
weight: float
|
||
|
||
|
||
# ---------- Özetleme/optimizasyon ----------
|
||
|
||
def simplify_curve(keys: List[Tuple[int, np.ndarray]], eps: float) -> List[Tuple[int, np.ndarray]]:
|
||
"""
|
||
RDP benzeri anahtar azaltma. keys: (frame, valueVector)
|
||
eps: maksimum sapma toleransı
|
||
|
||
Özyineleme yerine açık yığın kullanır: uzun, salınımlı kanallarda RDP derinliği
|
||
O(n) olabiliyor ve varsayılan 1000'lik limitte RecursionError veriyordu.
|
||
"""
|
||
if len(keys) <= 2:
|
||
return keys
|
||
|
||
frames = np.array([k for k, _ in keys], dtype=np.float64)
|
||
values = np.stack([v for _, v in keys]).astype(np.float64)
|
||
|
||
keep = np.zeros(len(keys), dtype=bool)
|
||
keep[0] = True
|
||
keep[-1] = True
|
||
|
||
stack: List[Tuple[int, int]] = [(0, len(keys) - 1)]
|
||
while stack:
|
||
idx0, idx1 = stack.pop()
|
||
if idx1 - idx0 < 2:
|
||
continue
|
||
f0, f1 = frames[idx0], frames[idx1]
|
||
df = f1 - f0
|
||
if df == 0:
|
||
continue
|
||
v0, v1 = values[idx0], values[idx1]
|
||
t = (frames[idx0 + 1:idx1] - f0) / df
|
||
interp = v0[None, :] * (1 - t)[:, None] + v1[None, :] * t[:, None]
|
||
dist = np.linalg.norm(values[idx0 + 1:idx1] - interp, axis=1)
|
||
if dist.size == 0:
|
||
continue
|
||
rel_idx = int(np.argmax(dist))
|
||
if float(dist[rel_idx]) > eps:
|
||
split = idx0 + 1 + rel_idx
|
||
keep[split] = True
|
||
stack.append((idx0, split))
|
||
stack.append((split, idx1))
|
||
|
||
return [(int(frames[i]), values[i].astype(np.float32))
|
||
for i in range(len(keys)) if keep[i]]
|
||
|
||
|
||
def slerp(q0: np.ndarray, q1: np.ndarray, t: float) -> np.ndarray:
|
||
# q0, q1 normalize
|
||
q0 = quat_normalize(q0)
|
||
q1 = quat_normalize(q1)
|
||
d = quat_dot(q0, q1)
|
||
if d < 0.0:
|
||
q1 = -q1
|
||
d = -d
|
||
if d > 0.9995:
|
||
return quat_normalize(q0 + t * (q1 - q0))
|
||
theta_0 = math.acos(max(min(d, 1.0), -1.0))
|
||
sin_theta_0 = math.sin(theta_0)
|
||
theta = theta_0 * t
|
||
sin_theta = math.sin(theta)
|
||
s0 = math.cos(theta) - d * sin_theta / sin_theta_0
|
||
s1 = sin_theta / sin_theta_0
|
||
return quat_normalize((s0 * q0) + (s1 * q1))
|
||
|
||
|
||
def simplify_quat_curve(keys: List[Tuple[int, np.ndarray]], eps_rad: float) -> List[Tuple[int, np.ndarray]]:
|
||
if len(keys) <= 2:
|
||
return keys
|
||
|
||
frames = np.array([k for k, _ in keys], dtype=np.float64)
|
||
quats = np.stack([q for _, q in keys]).astype(np.float64)
|
||
|
||
# Açısal hata ölçümünden ÖNCE normalize et. Aksi halde |q| > 1 olan dosyalarda
|
||
# dot > 1 olur, acos 1.0'a kırpılır ve her örnek için hata 0 görünür; kanal
|
||
# iki keyframe'e çöker.
|
||
norms = np.linalg.norm(quats, axis=1, keepdims=True)
|
||
norms[norms == 0.0] = 1.0
|
||
quats = quats / norms
|
||
|
||
# işaret sürekliliği (çift örtü)
|
||
for i in range(1, len(quats)):
|
||
if np.dot(quats[i - 1], quats[i]) < 0:
|
||
quats[i] = -quats[i]
|
||
|
||
def ang_err(q, p):
|
||
d = abs(float(np.dot(q, p)))
|
||
d = max(min(d, 1.0), -1.0)
|
||
return 2.0 * math.acos(d) # radyan
|
||
|
||
keep = np.zeros(len(keys), dtype=bool)
|
||
keep[0] = True
|
||
keep[-1] = True
|
||
|
||
stack: List[Tuple[int, int]] = [(0, len(keys) - 1)]
|
||
while stack:
|
||
idx0, idx1 = stack.pop()
|
||
if idx1 - idx0 < 2:
|
||
continue
|
||
f0, f1 = frames[idx0], frames[idx1]
|
||
df = f1 - f0
|
||
if df == 0:
|
||
continue
|
||
q0, q1 = quats[idx0], quats[idx1]
|
||
ts = (frames[idx0 + 1:idx1] - f0) / df
|
||
max_err = 0.0
|
||
max_i = -1
|
||
for j, t in enumerate(ts):
|
||
q = slerp(q0, q1, float(t))
|
||
e = ang_err(quats[idx0 + 1 + j], q)
|
||
if e > max_err:
|
||
max_err = e
|
||
max_i = idx0 + 1 + j
|
||
if max_err > eps_rad and max_i >= 0:
|
||
keep[max_i] = True
|
||
stack.append((idx0, max_i))
|
||
stack.append((max_i, idx1))
|
||
|
||
return [(int(frames[i]), quat_normalize(quats[i]).astype(np.float32))
|
||
for i in range(len(keys)) if keep[i]]
|
||
|
||
|
||
# ---------- Asıl optimizasyon akışı ----------
|
||
|
||
def optimize_vmd(input_path: str, output_path: str,
|
||
pos_eps: float = 0.05,
|
||
rot_eps_deg: float = 0.5,
|
||
morph_eps: float = 1e-3,
|
||
key_step: int = 1,
|
||
preserve_end_keys: bool = True,
|
||
remove_depth: bool = False,
|
||
depth_smooth_window: int = 0,
|
||
depth_scale: float = 1.0,
|
||
stabilize_ground_flag: bool = False,
|
||
ground_target_y: float = 0.0,
|
||
ground_use_feet_only: bool = True,
|
||
ground_smooth_window: int = 0,
|
||
ground_scale: float = 1.0,
|
||
ground_exclude_ik: bool = True,
|
||
flatten_interp: bool = False,
|
||
force_overwrite: bool = False,
|
||
replace_xr_with: Optional[str] = "Barış Keser",
|
||
progress: Optional[Callable[[str, int, int], None]] = None,
|
||
log: Optional[Callable[[str], None]] = None,
|
||
should_cancel: Optional[Callable[[], bool]] = None):
|
||
"""
|
||
VMD Motion Optimizer by Barış Keser (barkeser2002)
|
||
- pos_eps: pozisyon için max dünyasal sapma (model birimi)
|
||
- rot_eps_deg: quaternion açısal hata eşiği (derece)
|
||
- morph_eps: morph ağırlığı için tolerans
|
||
- key_step: her n karede bir downsample başlangıç filtresi (opsiyonel)
|
||
- preserve_end_keys: her kanalın ilk/son karesini koru
|
||
- flatten_interp: interpolasyon eğrilerini koruma, MMD varsayılanını yaz
|
||
- force_overwrite: çıktı == girdi olsa bile üzerine yaz
|
||
- should_cancel: True döndürürse işlem CancelledError ile durur
|
||
"""
|
||
def _log(msg: str):
|
||
if log:
|
||
try:
|
||
log(msg)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
print(msg)
|
||
|
||
def _check_cancel():
|
||
if should_cancel is not None and should_cancel():
|
||
raise CancelledError("İşlem kullanıcı tarafından iptal edildi")
|
||
|
||
if not os.path.exists(input_path):
|
||
raise FileNotFoundError(f"Girdi bulunamadı: {input_path}")
|
||
if not os.path.isfile(input_path):
|
||
raise ValueError(f"Girdi bir dosya değil: {input_path}")
|
||
if not output_path:
|
||
raise ValueError("Geçerli çıktı yolu verilmeli")
|
||
|
||
# Girdinin üzerine yazmayı engelle - v1.0.4'te '.VMD' uzantılı bir dosya
|
||
# sessizce kendi üzerine yazılıyordu.
|
||
if not force_overwrite and os.path.exists(output_path):
|
||
if os.path.realpath(input_path) == os.path.realpath(output_path):
|
||
raise ValueError(
|
||
"Çıktı yolu girdi ile aynı; kaynak dosyanın üzerine yazılmasını "
|
||
"engellemek için işlem durduruldu. Farklı bir çıktı verin "
|
||
"(veya bilerek istiyorsanız --force kullanın)."
|
||
)
|
||
|
||
out_dir = os.path.dirname(os.path.abspath(output_path)) or '.'
|
||
try:
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
except OSError as e:
|
||
raise ValueError(f"Çıktı klasörü oluşturulamadı: {out_dir} ({e})")
|
||
|
||
# Parametre doğrulama / düzeltme
|
||
if pos_eps < 0:
|
||
_log(f"Uyarı: pos_eps negatif ({pos_eps}), mutlak değeri alınacak")
|
||
pos_eps = abs(pos_eps)
|
||
if rot_eps_deg < 0:
|
||
_log(f"Uyarı: rot_eps_deg negatif ({rot_eps_deg}), mutlak değeri alınacak")
|
||
rot_eps_deg = abs(rot_eps_deg)
|
||
if morph_eps < 0:
|
||
_log(f"Uyarı: morph_eps negatif ({morph_eps}), mutlak değeri alınacak")
|
||
morph_eps = abs(morph_eps)
|
||
if key_step < 1:
|
||
_log(f"Uyarı: key_step<1 ({key_step}), 1 olarak ayarlanıyor")
|
||
key_step = 1
|
||
|
||
_log(f"Girdi: {input_path}")
|
||
_log(f"Çıktı: {output_path}")
|
||
|
||
fixed_path = _maybe_fix_vmd_header(input_path)
|
||
read_path = fixed_path or input_path
|
||
if fixed_path:
|
||
_log("Başlık düzeltmesi uygulandı (geçici dosya ile okunuyor)")
|
||
try:
|
||
m = read_vmd(read_path, log=_log)
|
||
finally:
|
||
# Geçici kopyayı her durumda temizle (v1.0.4 %TEMP%'te bırakıyordu)
|
||
if fixed_path:
|
||
try:
|
||
os.unlink(fixed_path)
|
||
except OSError:
|
||
pass
|
||
|
||
if m is None:
|
||
raise RuntimeError("VMD dosyası okunamadı. Dosya biçimi desteklenmiyor veya bozuk.")
|
||
|
||
_check_cancel()
|
||
src_bones, src_morphs = len(m.bones), len(m.morphs)
|
||
_log(f"Model: '{m.model_name}', kemik keyleri={src_bones}, morph keyleri={src_morphs}")
|
||
if m.trailing:
|
||
_log(f"Kamera/ışık/gölge/IK blokları korunuyor ({len(m.trailing)} bayt)")
|
||
|
||
if remove_depth:
|
||
_log("Depth: Z hizası kaldırma başlıyor")
|
||
remove_depth_alignment(m, smooth_window=depth_smooth_window,
|
||
scale=depth_scale, log=_log)
|
||
_log("Depth: tamamlandı")
|
||
|
||
if stabilize_ground_flag:
|
||
_log("Ground: stabilizasyon başlıyor")
|
||
stabilize_ground(m, target_y=ground_target_y, use_feet_only=ground_use_feet_only,
|
||
smooth_window=ground_smooth_window, scale=ground_scale,
|
||
exclude_ik=ground_exclude_ik, log=_log)
|
||
_log("Ground: tamamlandı")
|
||
|
||
_check_cancel()
|
||
|
||
# Kemik motionları
|
||
bone_channels: Dict[str, List[BoneKey]] = defaultdict(list)
|
||
for f in m.bones:
|
||
bone_channels[f.name].append(
|
||
BoneKey(
|
||
frame=int(f.frame),
|
||
loc=(float(f.pos[0]), float(f.pos[1]), float(f.pos[2])),
|
||
rot=(float(f.quat[0]), float(f.quat[1]), float(f.quat[2]), float(f.quat[3])),
|
||
interp=f.interp,
|
||
)
|
||
)
|
||
|
||
# Morph motionları
|
||
morph_channels: Dict[str, List[MorphKey]] = defaultdict(list)
|
||
for f in m.morphs:
|
||
morph_channels[f.name].append(MorphKey(frame=int(f.frame), weight=float(f.weight)))
|
||
|
||
# Kemik kanallarını optimize et
|
||
new_bone_frames: List[BoneFrame] = []
|
||
rot_eps_rad = math.radians(rot_eps_deg)
|
||
|
||
bone_items = list(bone_channels.items())
|
||
iterator = bone_items if progress is not None else tqdm(bone_items, desc='Bones')
|
||
for idx_bone, (bone, keys) in enumerate(iterator, start=1):
|
||
_check_cancel()
|
||
keys.sort(key=lambda k: k.frame)
|
||
|
||
# opsiyonel kaba downsample
|
||
if key_step > 1 and len(keys) > 2:
|
||
keys = [k for i, k in enumerate(keys)
|
||
if i == 0 or i == len(keys) - 1 or i % key_step == 0]
|
||
|
||
pos_keys = [(k.frame, np.array(k.loc, dtype=np.float32)) for k in keys]
|
||
rot_keys = [(k.frame, np.array(k.rot, dtype=np.float32)) for k in keys]
|
||
# Orijinal interpolasyon eğrilerini kare numarasına göre sakla
|
||
interp_map = {k.frame: k.interp for k in keys}
|
||
|
||
simp_pos = simplify_curve(pos_keys, pos_eps)
|
||
simp_rot = simplify_quat_curve(rot_keys, rot_eps_rad)
|
||
|
||
# uçları koru (simplify_* zaten uçları tutar; savunma amaçlı)
|
||
if preserve_end_keys:
|
||
first_f, last_f = keys[0].frame, keys[-1].frame
|
||
if simp_pos[0][0] != first_f:
|
||
simp_pos = [(first_f, pos_keys[0][1])] + simp_pos
|
||
if simp_pos[-1][0] != last_f:
|
||
simp_pos = simp_pos + [(last_f, pos_keys[-1][1])]
|
||
if simp_rot[0][0] != first_f:
|
||
simp_rot = [(first_f, rot_keys[0][1])] + simp_rot
|
||
if simp_rot[-1][0] != last_f:
|
||
simp_rot = simp_rot + [(last_f, rot_keys[-1][1])]
|
||
|
||
pos_map = {f: v for f, v in simp_pos}
|
||
rot_map = {f: v for f, v in simp_rot}
|
||
pos_frames = sorted(pos_map)
|
||
rot_frames = sorted(rot_map)
|
||
merged_frames = sorted(set(pos_frames) | set(rot_frames))
|
||
|
||
pos_arr = np.asarray(pos_frames, dtype=np.int64)
|
||
rot_arr = np.asarray(rot_frames, dtype=np.int64)
|
||
|
||
for fr in merged_frames:
|
||
p = pos_map.get(fr)
|
||
if p is None:
|
||
# O(1) komşu arama (eski kod her kare için listeyi baştan tarıyordu)
|
||
i = int(np.searchsorted(pos_arr, fr))
|
||
prev = pos_frames[max(i - 1, 0)]
|
||
nxt = pos_frames[min(i, len(pos_frames) - 1)]
|
||
if prev == nxt:
|
||
p = pos_map[prev]
|
||
else:
|
||
t = (fr - prev) / float(nxt - prev)
|
||
p = pos_map[prev] * (1 - t) + pos_map[nxt] * t
|
||
r = rot_map.get(fr)
|
||
if r is None:
|
||
i = int(np.searchsorted(rot_arr, fr))
|
||
prev = rot_frames[max(i - 1, 0)]
|
||
nxt = rot_frames[min(i, len(rot_frames) - 1)]
|
||
if prev == nxt:
|
||
r = rot_map[prev]
|
||
else:
|
||
t = (fr - prev) / float(nxt - prev)
|
||
r = slerp(rot_map[prev], rot_map[nxt], t)
|
||
|
||
if flatten_interp:
|
||
interp = MMD_DEFAULT_INTERP
|
||
else:
|
||
interp = interp_map.get(fr) or MMD_DEFAULT_INTERP
|
||
|
||
new_bone_frames.append(
|
||
BoneFrame(
|
||
name=bone,
|
||
frame=int(fr),
|
||
pos=np.array([float(p[0]), float(p[1]), float(p[2])], dtype=np.float32),
|
||
quat=np.array([float(r[0]), float(r[1]), float(r[2]), float(r[3])],
|
||
dtype=np.float32),
|
||
interp=interp,
|
||
)
|
||
)
|
||
if progress is not None:
|
||
progress('Bones', idx_bone, len(bone_items))
|
||
|
||
# Morph kanallarını optimize et
|
||
new_morph_frames: List[MorphFrame] = []
|
||
morph_items = list(morph_channels.items())
|
||
m_iterator = morph_items if progress is not None else tqdm(morph_items, desc='Morphs')
|
||
for idx_m, (morph, keys) in enumerate(m_iterator, start=1):
|
||
_check_cancel()
|
||
keys.sort(key=lambda k: k.frame)
|
||
|
||
# Sabit kalan aralıkların HEM başını HEM sonunu koru. Yalnızca başını tutmak
|
||
# (v1.0.4) bir "tutuş"u uzun bir rampaya çeviriyordu: 0-100 arası açık kalan
|
||
# bir göz, 105'teki kırpma yüzünden 0'dan itibaren yavaşça kapanıyordu.
|
||
cleaned: List[Tuple[int, np.ndarray]] = []
|
||
last_w = None
|
||
pending: Optional[Tuple[int, float]] = None
|
||
for k in keys:
|
||
w = 0.0 if abs(k.weight) < morph_eps else k.weight
|
||
if last_w is None or abs(w - last_w) > morph_eps:
|
||
if pending is not None:
|
||
cleaned.append((pending[0], np.array([pending[1]], dtype=np.float32)))
|
||
pending = None
|
||
cleaned.append((k.frame, np.array([w], dtype=np.float32)))
|
||
last_w = w
|
||
else:
|
||
pending = (k.frame, last_w)
|
||
if pending is not None:
|
||
cleaned.append((pending[0], np.array([pending[1]], dtype=np.float32)))
|
||
|
||
if len(cleaned) <= 1:
|
||
if cleaned:
|
||
new_morph_frames.append(MorphFrame(name=morph, frame=int(cleaned[0][0]),
|
||
weight=float(cleaned[0][1][0])))
|
||
continue
|
||
|
||
simp = simplify_curve(cleaned, morph_eps)
|
||
|
||
if preserve_end_keys:
|
||
first_f, last_f = keys[0].frame, keys[-1].frame
|
||
if simp[0][0] != first_f:
|
||
simp = [(first_f, cleaned[0][1])] + simp
|
||
if simp[-1][0] != last_f:
|
||
simp = simp + [(last_f, cleaned[-1][1])]
|
||
|
||
for fr, val in simp:
|
||
new_morph_frames.append(MorphFrame(name=morph, frame=int(fr),
|
||
weight=float(val[0])))
|
||
if progress is not None:
|
||
progress('Morphs', idx_m, len(morph_items))
|
||
|
||
_check_cancel()
|
||
|
||
out_model_name = m.model_name
|
||
if replace_xr_with and (out_model_name.strip() == 'XR Animator'):
|
||
out_model_name = replace_xr_with
|
||
|
||
new_motion = Motion(
|
||
model_name=out_model_name,
|
||
bones=sorted(new_bone_frames, key=lambda b: (b.name, b.frame)),
|
||
morphs=sorted(new_morph_frames, key=lambda b: (b.name, b.frame)),
|
||
trailing=m.trailing,
|
||
)
|
||
|
||
write_vmd(output_path, new_motion)
|
||
|
||
def _pct(new: int, old: int) -> str:
|
||
if not old:
|
||
return "-"
|
||
return f"%{100.0 * (old - new) / old:.1f}"
|
||
|
||
_log(f"Kemik keyleri: {src_bones} -> {len(new_motion.bones)} ({_pct(len(new_motion.bones), src_bones)} azaldı)")
|
||
_log(f"Morph keyleri: {src_morphs} -> {len(new_motion.morphs)} ({_pct(len(new_motion.morphs), src_morphs)} azaldı)")
|
||
_log("Kaydedildi")
|
||
return output_path
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(
|
||
description='VMD motion optimizasyonu (RDP/SLERP). '
|
||
'VMD Motion Optimizer by Barış Keser (barkeser2002)')
|
||
ap.add_argument('input', help='.vmd dosya yolu')
|
||
ap.add_argument('-o', '--output', default=None,
|
||
help='çıktı .vmd dosyası (varsayılan: <input>_optimized.vmd)')
|
||
ap.add_argument('--pos-eps', type=float, default=0.05, help='pozisyon toleransı')
|
||
ap.add_argument('--rot-eps-deg', type=float, default=0.5, help='rotasyon toleransı (derece)')
|
||
ap.add_argument('--morph-eps', type=float, default=1e-3, help='morph toleransı')
|
||
ap.add_argument('--key-step', type=int, default=1,
|
||
help='kaba downsample adımı (örn. 2=her 2. anahtar)')
|
||
ap.add_argument('--no-preserve-end', action='store_true', help='kanal uçlarını koruma')
|
||
ap.add_argument('--flatten-interp', action='store_true',
|
||
help='interpolasyon eğrilerini koruma, MMD varsayılanını yaz')
|
||
ap.add_argument('--force', action='store_true',
|
||
help='çıktı girdi ile aynı olsa bile üzerine yaz (TEHLİKELİ)')
|
||
# Depth options
|
||
ap.add_argument('--remove-depth', action='store_true',
|
||
help='kök kemikteki global Z sürüklenmesini kaldır (--depth-smooth gerekir)')
|
||
ap.add_argument('--depth-smooth', type=int, default=0,
|
||
help='depth eğilim penceresi (>=2 zorunlu, öneri: 30)')
|
||
ap.add_argument('--depth-scale', type=float, default=1.0, help='depth ölçek')
|
||
# Ground options
|
||
ap.add_argument('--stabilize-ground', action='store_true', help='zemine sabitle')
|
||
ap.add_argument('--ground-target-y', type=float, default=0.0, help='hedef zemin Y')
|
||
ap.add_argument('--ground-smooth', type=int, default=0, help='zemin için smooth window')
|
||
ap.add_argument('--ground-scale', type=float, default=1.0, help='zemin ofset ölçek')
|
||
ap.add_argument('--ground-all-bones', action='store_true',
|
||
help='tüm kemikleri kullan (varsayılan: sadece ayak)')
|
||
ap.add_argument('--ground-exclude-ik', dest='ground_exclude_ik',
|
||
action='store_true', default=True,
|
||
help='IK kemiklerini zemin ölçümünden çıkar (varsayılan)')
|
||
ap.add_argument('--ground-include-ik', dest='ground_exclude_ik',
|
||
action='store_false',
|
||
help='IK kemiklerini zemin ölçümüne dahil et')
|
||
# Model adı düzeltme
|
||
ap.add_argument('--replace-xr-with', type=str, default='Barış Keser',
|
||
help='"XR Animator" model adını bununla değiştir')
|
||
args = ap.parse_args()
|
||
|
||
if args.output:
|
||
output = args.output
|
||
else:
|
||
stem, _ext = os.path.splitext(args.input)
|
||
output = stem + '_optimized.vmd'
|
||
|
||
try:
|
||
optimize_vmd(
|
||
input_path=args.input,
|
||
output_path=output,
|
||
pos_eps=args.pos_eps,
|
||
rot_eps_deg=args.rot_eps_deg,
|
||
morph_eps=args.morph_eps,
|
||
key_step=args.key_step,
|
||
preserve_end_keys=not args.no_preserve_end,
|
||
remove_depth=args.remove_depth,
|
||
depth_smooth_window=args.depth_smooth,
|
||
depth_scale=args.depth_scale,
|
||
stabilize_ground_flag=args.stabilize_ground,
|
||
ground_target_y=args.ground_target_y,
|
||
ground_use_feet_only=not args.ground_all_bones,
|
||
ground_smooth_window=args.ground_smooth,
|
||
ground_scale=args.ground_scale,
|
||
ground_exclude_ik=args.ground_exclude_ik,
|
||
flatten_interp=args.flatten_interp,
|
||
force_overwrite=args.force,
|
||
replace_xr_with=args.replace_xr_with,
|
||
log=print,
|
||
)
|
||
print('Kaydedildi:', output)
|
||
except CancelledError:
|
||
print('İptal edildi', file=sys.stderr)
|
||
sys.exit(130)
|
||
except Exception as e:
|
||
print(f'Hata: {type(e).__name__}: {e}', file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|