Files
Barış Keser 60f49df9aa Çekirdek VMD doğruluğu: veri kaybına yol açan 10 hatayı düzelt
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ı.
2026-08-03 10:12:06 +03:00

1157 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()