Fix : 유로 필터 업데이트 페이셜

This commit is contained in:
user 2026-02-20 02:08:28 +09:00
parent bff3acb612
commit 4d84234a88
6 changed files with 220 additions and 21 deletions

View File

@ -15,7 +15,8 @@ public class StreamingleFacialReceiverEditor : Editor
private VisualElement portButtonsContainer;
private Label activePortValue;
private VisualElement statusContainer;
private VisualElement filteringFields;
private VisualElement emaFields;
private VisualElement euroFields;
public override VisualElement CreateInspectorGUI()
{
@ -37,7 +38,8 @@ public class StreamingleFacialReceiverEditor : Editor
statusContainer = root.Q("statusContainer");
activePortValue = root.Q<Label>("activePortValue");
portButtonsContainer = root.Q("portButtonsContainer");
filteringFields = root.Q("filteringFields");
emaFields = root.Q("emaFields");
euroFields = root.Q("euroFields");
// Auto-find button
var autoFindBtn = root.Q<Button>("autoFindBtn");
@ -48,13 +50,13 @@ public class StreamingleFacialReceiverEditor : Editor
// Build dynamic port buttons
RebuildPortButtons();
// Track enableFiltering for conditional visibility
var enableFilteringProp = serializedObject.FindProperty("enableFiltering");
UpdateFilteringVisibility(enableFilteringProp.boolValue);
// Track filterMode for conditional visibility of EMA/Euro fields
var filterModeProp = serializedObject.FindProperty("filterMode");
UpdateFilterModeVisibility(filterModeProp.enumValueIndex);
root.TrackPropertyValue(enableFilteringProp, prop =>
root.TrackPropertyValue(filterModeProp, prop =>
{
UpdateFilteringVisibility(prop.boolValue);
UpdateFilterModeVisibility(prop.enumValueIndex);
});
// Track availablePorts and activePortIndex changes to rebuild port buttons
@ -70,10 +72,13 @@ public class StreamingleFacialReceiverEditor : Editor
return root;
}
private void UpdateFilteringVisibility(bool enabled)
private void UpdateFilterModeVisibility(int modeIndex)
{
if (filteringFields == null) return;
filteringFields.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
// FilterMode: 0=None, 1=EMA, 2=OneEuro
if (emaFields != null)
emaFields.style.display = modeIndex == 1 ? DisplayStyle.Flex : DisplayStyle.None;
if (euroFields != null)
euroFields.style.display = modeIndex == 2 ? DisplayStyle.Flex : DisplayStyle.None;
}
private void RebuildPortButtons()

View File

@ -127,13 +127,14 @@
/* ---- Data Filtering ---- */
#filteringFields {
#emaFields {
padding-left: 16px;
margin-top: 4px;
}
#filteringFields--hidden {
display: none;
#euroFields {
padding-left: 16px;
margin-top: 4px;
}
/* ---- Facial Intensity ---- */

View File

@ -36,14 +36,21 @@
<!-- Data Filtering -->
<ui:VisualElement class="section">
<ui:Foldout text="Data Filtering" value="true" class="section-foldout">
<uie:PropertyField binding-path="enableFiltering" label="Enable"/>
<ui:VisualElement name="filteringFields">
<uie:PropertyField binding-path="filterMode" label="Filter Mode"/>
<!-- EMA Filter Fields -->
<ui:VisualElement name="emaFields">
<uie:PropertyField binding-path="smoothingFactor" label="Smoothing"/>
<uie:PropertyField binding-path="maxBlendShapeDelta" label="Max BlendShape Delta"/>
<uie:PropertyField binding-path="maxRotationDelta" label="Max Rotation Delta"/>
<uie:PropertyField binding-path="fastBlendShapeMultiplier" label="Fast BS Multiplier"/>
<uie:PropertyField binding-path="spikeToleranceFrames" label="Spike Tolerance"/>
</ui:VisualElement>
<!-- 1€ Euro Filter Fields -->
<ui:VisualElement name="euroFields">
<uie:PropertyField binding-path="euroMinCutoff" label="Min Cutoff (Hz)"/>
<uie:PropertyField binding-path="euroBeta" label="Beta (Speed Coeff)"/>
<uie:PropertyField binding-path="euroDCutoff" label="D Cutoff (Hz)"/>
</ui:VisualElement>
</ui:Foldout>
</ui:VisualElement>

View File

@ -0,0 +1,112 @@
using UnityEngine;
/// <summary>
/// 1€ Euro Filter 구현 (Casiez et al., CHI 2012)
/// 속도 기반 적응형 cutoff로 "느린 움직임 = 강한 스무딩, 빠른 움직임 = 즉시 반응"
/// </summary>
public class OneEuroFilter
{
private float freq;
private float minCutoff;
private float beta;
private float dCutoff;
private LowPassFilter xFilter;
private LowPassFilter dxFilter;
private float lastTimestamp;
public OneEuroFilter(float frequency, float minCutoff = 1.0f, float beta = 0.0f, float dCutoff = 1.0f)
{
freq = frequency;
this.minCutoff = minCutoff;
this.beta = beta;
this.dCutoff = dCutoff;
xFilter = new LowPassFilter(ComputeAlpha(minCutoff));
dxFilter = new LowPassFilter(ComputeAlpha(dCutoff));
lastTimestamp = -1f;
}
private float ComputeAlpha(float cutoff)
{
float te = 1.0f / freq;
float tau = 1.0f / (2.0f * Mathf.PI * cutoff);
return 1.0f / (1.0f + tau / te);
}
public float Filter(float value, float timestamp)
{
if (lastTimestamp >= 0f && timestamp > lastTimestamp)
{
freq = 1.0f / (timestamp - lastTimestamp);
}
lastTimestamp = timestamp;
// 속도(미분) 추정
float dvalue = xFilter.HasLastRawValue
? (value - xFilter.LastRawValue) * freq
: 0.0f;
// 미분값 필터링
float edvalue = dxFilter.FilterWithAlpha(dvalue, ComputeAlpha(dCutoff));
// 적응형 cutoff: 속도가 빠르면 cutoff↑ (스무딩↓)
float cutoff = minCutoff + beta * Mathf.Abs(edvalue);
// 신호 필터링
return xFilter.FilterWithAlpha(value, ComputeAlpha(cutoff));
}
public void UpdateParams(float minCutoff, float beta, float dCutoff)
{
this.minCutoff = Mathf.Max(0.001f, minCutoff);
this.beta = Mathf.Max(0f, beta);
this.dCutoff = Mathf.Max(0.001f, dCutoff);
}
public void Reset()
{
xFilter = new LowPassFilter(ComputeAlpha(minCutoff));
dxFilter = new LowPassFilter(ComputeAlpha(dCutoff));
lastTimestamp = -1f;
}
/// <summary>
/// 1차 지수 이동 평균 저역통과 필터
/// </summary>
private class LowPassFilter
{
private float alpha;
private float smoothed;
private float rawValue;
private bool initialized;
public bool HasLastRawValue => initialized;
public float LastRawValue => rawValue;
public LowPassFilter(float alpha, float initValue = 0f)
{
this.alpha = Mathf.Clamp01(alpha);
smoothed = initValue;
rawValue = initValue;
initialized = false;
}
public float FilterWithAlpha(float value, float newAlpha)
{
alpha = Mathf.Clamp01(newAlpha);
rawValue = value;
if (!initialized)
{
smoothed = value;
initialized = true;
}
else
{
smoothed = alpha * value + (1.0f - alpha) * smoothed;
}
return smoothed;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e0dff203e063fc4b92b4c514b5643f0

View File

@ -30,10 +30,15 @@ public class StreamingleFacialReceiver : MonoBehaviour
public int activePortIndex = 0;
public int LOCAL_PORT => availablePorts != null && activePortIndex < availablePorts.Length ? availablePorts[activePortIndex] : 49983;
// 데이터 필터링 설정
// 필터 모드 설정
public enum FilterMode { None, EMA, OneEuro }
[Header("Data Filtering")]
[Tooltip("데이터 스무딩/필터링 활성화")]
public bool enableFiltering = true;
[Tooltip("필터링 모드: None=필터없음, EMA=기존 스무딩+스파이크, OneEuro=1€ 적응형 필터")]
public FilterMode filterMode = FilterMode.OneEuro;
// EMA 필터 설정 (기존 호환)
[Header("EMA Filter Settings")]
[Tooltip("스무딩 강도 (0=필터없음, 1=최대 스무딩). 프레임레이트 독립적으로 동작")]
[Range(0f, 0.95f)]
public float smoothingFactor = 0.1f;
@ -50,7 +55,23 @@ public class StreamingleFacialReceiver : MonoBehaviour
[Range(1, 5)]
public int spikeToleranceFrames = 2;
// 필터링용 이전 값 저장
// 1€ Euro Filter 설정
[Header("1€ Euro Filter Settings")]
[Tooltip("최소 cutoff 주파수 (Hz). 낮을수록 정지 시 스무딩 강함, 높을수록 반응 빠름")]
[Range(0.01f, 10f)]
public float euroMinCutoff = 1.0f;
[Tooltip("속도 계수. 높을수록 빠른 움직임에 즉시 반응")]
[Range(0f, 20f)]
public float euroBeta = 0.5f;
[Tooltip("미분 cutoff 주파수 (Hz). 속도 추정의 스무딩. 보통 1.0 유지")]
[Range(0.1f, 5f)]
public float euroDCutoff = 1.0f;
// Euro 필터 인스턴스 (블렌드쉐이프별)
private Dictionary<string, OneEuroFilter> euroFilters = new Dictionary<string, OneEuroFilter>();
private float euroMinCutoffPrev, euroBetaPrev, euroDCutoffPrev;
// EMA 필터링용 이전 값 저장
private Dictionary<string, float> prevBlendShapeValues = new Dictionary<string, float>();
// 연속 스파이크 추적 (같은 방향으로 연속이면 실제 움직임)
@ -219,6 +240,21 @@ public class StreamingleFacialReceiver : MonoBehaviour
void OnValidate()
{
intensityMapDirty = true;
SyncEuroParams();
}
void SyncEuroParams()
{
if (euroFilters != null)
{
foreach (var filter in euroFilters.Values)
{
filter.UpdateParams(euroMinCutoff, euroBeta, euroDCutoff);
}
}
euroMinCutoffPrev = euroMinCutoff;
euroBetaPrev = euroBeta;
euroDCutoffPrev = euroDCutoff;
}
void RebuildIntensityOverrideMap()
@ -282,7 +318,7 @@ public class StreamingleFacialReceiver : MonoBehaviour
weight = Mathf.Clamp(weight, 0f, 100f);
// 필터링 적용
if (enableFiltering)
if (filterMode != FilterMode.None)
{
weight = FilterBlendShapeValue(normalizedName, weight);
}
@ -539,9 +575,45 @@ public class StreamingleFacialReceiver : MonoBehaviour
}
/// <summary>
/// BlendShape 값 필터링: 연속 스파이크 판별 + 카테고리별 임계값 + 프레임독립 EMA
/// BlendShape 값 필터링: filterMode에 따라 EMA 또는 1€ Euro Filter 적용
/// </summary>
float FilterBlendShapeValue(string name, float rawValue)
{
if (filterMode == FilterMode.OneEuro)
return FilterOneEuro(name, rawValue);
return FilterEMA(name, rawValue);
}
/// <summary>
/// 1€ Euro Filter: 속도 기반 적응형 cutoff
/// </summary>
float FilterOneEuro(string name, float rawValue)
{
float t = Time.time;
if (!euroFilters.TryGetValue(name, out var filter))
{
filter = new OneEuroFilter(60f, euroMinCutoff, euroBeta, euroDCutoff);
euroFilters[name] = filter;
// 첫 샘플은 필터 초기화용 → 그대로 반환
filter.Filter(rawValue, t);
return rawValue;
}
// 파라미터 변경 감지 → 모든 필터 업데이트
if (euroMinCutoffPrev != euroMinCutoff || euroBetaPrev != euroBeta || euroDCutoffPrev != euroDCutoff)
{
SyncEuroParams();
}
return Mathf.Clamp(filter.Filter(rawValue, t), 0f, 100f);
}
/// <summary>
/// EMA 필터: 연속 스파이크 판별 + 카테고리별 임계값 + 프레임독립 EMA (기존 로직)
/// </summary>
float FilterEMA(string name, float rawValue)
{
if (prevBlendShapeValues.TryGetValue(name, out float prevValue))
{