This commit is contained in:
agomizi1cm committed 2026-08-09 21:41:44 +09:00
commit c5bcb95d74
172 files changed
+9487

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Unit tests."""
+4
View File
@@ -0,0 +1,4 @@
experiment_id,session_id,track_id,voice_mode,prompt_condition,prompt_played,pseudo_prompt,prompt_time,prompt_elapsed_time_sec,response_window_started,response_detected,reaction_time_sec,response_turn_level,turn_confirmed,turn_level,max_yaw_delta_toward_signage,face_detected_ever,pose_estimated_ever,face_detection_rate,pose_estimation_rate,valid_for_turn_analysis,valid_for_voice_analysis,final_status,completed,excluded,exclusion_reason,track_duration_sec,trigger_crossed,trigger_crossing_direction,movement_direction,camera_position_note,notes
exp,s1,1,prompt,prompt,True,False,t,1.0,True,True,0.8,weak,True,weak,25.0,True,True,0.8,0.7,True,True,completed,True,False,none,3.0,True,left_to_right,left_to_right,test,
exp,s2,2,control,control,False,True,t,1.0,True,False,,none,False,none,5.0,True,True,0.7,0.6,True,True,completed,True,False,none,3.0,True,left_to_right,left_to_right,test,
exp,s3,3,prompt,prompt,True,False,t,1.0,True,False,,,False,not_evaluable,,False,False,0.0,0.0,False,False,not_evaluable,True,False,no_pose_estimated,3.0,True,left_to_right,left_to_right,test,
1 experiment_id session_id track_id voice_mode prompt_condition prompt_played pseudo_prompt prompt_time prompt_elapsed_time_sec response_window_started response_detected reaction_time_sec response_turn_level turn_confirmed turn_level max_yaw_delta_toward_signage face_detected_ever pose_estimated_ever face_detection_rate pose_estimation_rate valid_for_turn_analysis valid_for_voice_analysis final_status completed excluded exclusion_reason track_duration_sec trigger_crossed trigger_crossing_direction movement_direction camera_position_note notes
2 exp s1 1 prompt prompt True False t 1.0 True True 0.8 weak True weak 25.0 True True 0.8 0.7 True True completed True False none 3.0 True left_to_right left_to_right test
3 exp s2 2 control control False True t 1.0 True False none False none 5.0 True True 0.7 0.6 True True completed True False none 3.0 True left_to_right left_to_right test
4 exp s3 3 prompt prompt True False t 1.0 True False False not_evaluable False False 0.0 0.0 False False not_evaluable True False no_pose_estimated 3.0 True left_to_right left_to_right test
+5
View File
@@ -0,0 +1,5 @@
from main import build_analysis_parser
def test_analysis_parser() -> None:
args=build_analysis_parser().parse_args(["--input","in","--output","out","--no-analysis-plots","--analysis-response-test","fisher"])
assert str(args.input)=="in" and args.no_analysis_plots and args.analysis_response_test=="fisher"
+13
View File
@@ -0,0 +1,13 @@
from pathlib import Path
import pytest
pd=pytest.importorskip("pandas")
from src.analysis_loader import AnalysisLoader
from src.config_loader import AnalysisSettings
from src.exceptions import ConfigurationError
CFG=AnalysisSettings(True,"in","out",True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",False,True,.05)
def test_load_find_convert_validate(tmp_path:Path)->None:
row={"experiment_id":"e","session_id":"s","track_id":"1","prompt_condition":"prompt","response_detected":"True","valid_for_voice_analysis":"False","valid_for_turn_analysis":"False","turn_confirmed":"False","turn_level":"not_evaluable","reaction_time_sec":"","max_yaw_delta_toward_signage":"","excluded":"False","exclusion_reason":"none"}
for i in range(2):pd.DataFrame([row]).to_csv(tmp_path/f"{i}_person_analysis_table.csv",index=False,encoding="utf-8-sig")
l=AnalysisLoader(CFG);paths=l.find_person_analysis_tables(tmp_path);df=l.load_person_analysis_tables(paths);assert len(df)==2 and bool(df.response_detected.iloc[0]) and not bool(df.valid_for_voice_analysis.iloc[0])
with pytest.raises(ConfigurationError):l.validate_person_analysis_columns(pd.DataFrame({"x":[]}))
+6
View File
@@ -0,0 +1,6 @@
from src.models import AnalysisConditionSummary,AnalysisRunSummary
def test_analysis_models() -> None:
condition=AnalysisConditionSummary("prompt",1,1,1,0,1.,0,0,None,None,None,None,None,None,0,1,0,0,1,0)
run=AnalysisRunSummary("a",1,1,1,0,1,0,"t","out","note")
assert condition.response_rate==1. and run.input_files_count==1
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
import pytest
pd=pytest.importorskip("pandas");pytest.importorskip("matplotlib")
from src.analysis_plots import AnalysisPlots
from src.config_loader import AnalysisSettings
from src.statistical_analyzer import StatisticalAnalyzer
def test_five_plots(tmp_path:Path)->None:
c=AnalysisSettings(True,"in",str(tmp_path),True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",True,True,.05)
df=pd.DataFrame([{"prompt_condition":"prompt","valid_for_voice_analysis":True,"response_detected":True,"reaction_time_sec":1.,"max_yaw_delta_toward_signage":20.,"turn_level":"weak","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"},{"prompt_condition":"control","valid_for_voice_analysis":True,"response_detected":False,"reaction_time_sec":None,"max_yaw_delta_toward_signage":5.,"turn_level":"none","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"}])
assert len(AnalysisPlots(c).generate(df,StatisticalAnalyzer(c).run_analysis(df)))==5
+12
View File
@@ -0,0 +1,12 @@
from pathlib import Path
from src.analysis_reporter import AnalysisReporter
from src.config_loader import AnalysisSettings
from src.models import AnalysisResult,AnalysisRunSummary,ResponseRateTestResult
def cfg(path:Path,overwrite:bool=True)->AnalysisSettings:return AnalysisSettings(True,"in",str(path),True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",False,overwrite,.05)
def empty(path:Path)->AnalysisResult:return AnalysisResult([],ResponseRateTestResult("none",0,0,0,0,None,None,None,None,None,"none",None,None,"empty"),[],[],[],AnalysisRunSummary("a",0,0,0,0,0,0,"t",str(path),"日本語"))
def test_six_bom_csvs_and_overwrite(tmp_path:Path)->None:
paths=AnalysisReporter(cfg(tmp_path)).write(empty(tmp_path));assert len(paths)==6 and all(p.read_bytes().startswith(b"\xef\xbb\xbf") for p in paths)
try:AnalysisReporter(cfg(tmp_path,False)).write(empty(tmp_path));assert False
except FileExistsError:pass
+16
View File
@@ -0,0 +1,16 @@
from src.analysis_table_builder import AnalysisTableBuilder
from src.config_loader import UnifiedLogSettings
from src.models import PersonStateSummary
CFG=UnifiedLogSettings(True,"x",True,True,True,True,30,True,True,True,True,False,False,"camera",True)
def test_prompt_control_disabled_records() -> None:
b=AnalysisTableBuilder(CFG,"sid")
for mode in ("prompt","control",None):
s=PersonStateSummary("exp",1,voice_mode=mode,prompt_played=mode=="prompt",pseudo_prompt=mode=="control",
response_detected=True,reaction_time_sec=.8,turn_confirmed=True,turn_level="weak",
max_yaw_delta_toward_signage=25.,valid_for_turn_analysis=False,exclusion_reason="no_pose_estimated")
r=b.build_record(s)
assert r.prompt_condition==(mode or "disabled") and r.camera_position_note=="camera"
assert r.response_detected and r.reaction_time_sec==.8 and r.valid_for_turn_analysis is False
@@ -0,0 +1,10 @@
"""Source-level guard for a GUI callback regression that occurred before first frame."""
from pathlib import Path
def test_runtime_callback_does_not_reference_screenshot_path() -> None:
source = (Path(__file__).parents[1] / "src" / "application.py").read_text(encoding="utf-8")
callback_body = source.split("def _emit_runtime_update", 1)[1]
assert "Saved screenshot" not in callback_body
screenshot_body = source.split("def _save_screenshot", 1)[1].split("def _emit_runtime_update", 1)[0]
assert 'LOGGER.info("Saved screenshot: %s", path)' in screenshot_body
+55
View File
@@ -0,0 +1,55 @@
from pathlib import Path
from unittest.mock import Mock
from src.audio_player import AudioPlayer
from src.config_loader import AudioSettings
def config(**values: object) -> AudioSettings:
base = dict(enabled=True, backend="pygame", file_path="prompt.wav", volume=.7, overlap_policy="skip")
base.update(values)
return AudioSettings(**base)
def test_disabled_and_missing_file_are_safe(tmp_path: Path) -> None:
assert AudioPlayer(config(enabled=False)).play() is False
player = AudioPlayer(config())
player.load(tmp_path / "missing.wav")
assert player.play() is False
def test_mock_backend_load_play_volume_and_close(tmp_path: Path) -> None:
path = tmp_path / "prompt.wav"
path.write_bytes(b"test")
channel = Mock()
channel.get_busy.return_value = False
sound = Mock()
sound.play.return_value = channel
mixer = Mock()
mixer.get_init.return_value = True
mixer.Sound.return_value = sound
backend = Mock(mixer=mixer)
player = AudioPlayer(config(), backend)
player.load(path)
assert player.play()
sound.set_volume.assert_called_once_with(.7)
player.close()
player.close()
def test_overlap_skip_and_restart(tmp_path: Path) -> None:
path = tmp_path / "prompt.wav"
path.write_bytes(b"x")
for policy, expected in (("skip", 1), ("restart", 2)):
channel = Mock()
channel.get_busy.return_value = True
sound = Mock(play=Mock(return_value=channel))
mixer = Mock()
mixer.get_init.return_value = True
mixer.Sound.return_value = sound
player = AudioPlayer(config(overlap_policy=policy), Mock(mixer=mixer))
player.load(path)
assert player.play()
player.play()
assert sound.play.call_count == expected
+6
View File
@@ -0,0 +1,6 @@
from pathlib import Path
from src.calibration_history import CalibrationHistory
from src.models import CalibrationChangeRecord
def test_calibration_roundtrip_bom(tmp_path:Path)->None:
h=CalibrationHistory(tmp_path/"h.csv");r=CalibrationChangeRecord("id1","now","学生","camera_position","横","正面","改善");h.append(r);assert h.path.read_bytes().startswith(b"\xef\xbb\xbf");assert h.load()[0].after_value=="正面"
def test_empty_history(tmp_path:Path)->None:assert CalibrationHistory(tmp_path/"none.csv").load()==[]
+79
View File
@@ -0,0 +1,79 @@
from pathlib import Path
import pytest
import yaml
from src.config_loader import load_config
from src.exceptions import ConfigurationError
DEFAULT = Path(__file__).parents[1] / "config" / "default.yaml"
def write_yaml(tmp_path: Path, values: dict[str, object]) -> Path:
path = tmp_path / "experiment.yaml"
path.write_text(yaml.safe_dump(values), encoding="utf-8")
return path
def test_recursive_merge_and_experiment_precedence(tmp_path: Path) -> None:
path = write_yaml(tmp_path, {"detector": {"confidence_threshold": 0.8}, "experiment": {"location": "lab"}})
config = load_config(DEFAULT, path)
assert config.detector.confidence_threshold == 0.8
assert config.detector.iou_threshold == 0.5
assert config.experiment.location == "lab"
assert config.experiment.experiment_id == "phase1_default"
def test_cli_override_is_last(tmp_path: Path) -> None:
path = write_yaml(tmp_path, {"detector": {"confidence_threshold": 0.6}})
config = load_config(DEFAULT, path, {"detector": {"confidence_threshold": 0.7}})
assert config.detector.confidence_threshold == 0.7
@pytest.mark.parametrize("override", [
{"detector": {"confidence_threshold": 1.1}},
{"evaluation_region": {"x1": -0.1}},
{"evaluation_region": {"x1": 0.9, "x2": 0.8}},
{"input": {"type": "video", "video_path": None}},
{"tracker": {"match_thresh": 1.1}},
{"crossing": {"minimum_track_points": 1}},
{"performance": {"skip_frames": 1}},
{"face": {"process_every_n_frames": 0}},
{"face": {"roi_height_ratio": 1.1}},
{"face": {"min_detection_confidence": -0.1}},
{"head_pose": {"ema_alpha": 0.0}},
{"head_pose": {"max_abs_angle": 0}},
{"face_logging": {"flush_interval_frames": 0}},
{"turn_detection": {"signage_yaw_direction": "sideways"}},
{"turn_detection": {"baseline_strategy": "unknown"}},
{"turn_detection": {"baseline_min_samples": 0}},
{"turn_detection": {"subtle_threshold_deg": 25, "weak_threshold_deg": 20}},
{"turn_detection": {"min_pose_estimation_rate_for_evaluation": 1.1}},
{"turn_logging": {"flush_interval_frames": 0}},
{"audio": {"backend": "unknown"}},
{"audio": {"volume": 1.1}},
{"audio": {"overlap_policy": "mix"}},
{"voice_prompt": {"mode": "random"}},
{"voice_prompt": {"trigger_strategy": "unknown"}},
{"voice_prompt": {"response_window_sec": 0}},
{"voice_prompt": {"fixed_position_axis": "z"}},
{"voice_logging": {"flush_interval_frames": 0}},
{"person_state": {"min_track_duration_sec": -1}},
{"person_state_logging": {"flush_interval_frames": 0}},
{"unified_logging": {"flush_interval_frames": 0}},
{"analysis": {"alpha": 1.0}},
{"analysis": {"response_rate_test": "bad"}},
{"gui": {"preview_max_fps": 0}},
{"gui": {"enabled": "yes"}},
{"pilot_diagnostics": {"min_valid_voice_analysis_rate": 1.1}},
{"pilot_diagnostics": {"max_condition_valid_count_ratio": 0.5}},
])
def test_invalid_config(tmp_path: Path, override: dict[str, object]) -> None:
with pytest.raises(ConfigurationError):
load_config(DEFAULT, write_yaml(tmp_path, override))
def test_skip_frames_allowed_when_tracker_disabled(tmp_path: Path) -> None:
path = write_yaml(tmp_path, {"tracker": {"enabled": False}, "performance": {"skip_frames": 2}})
assert load_config(DEFAULT, path).performance.skip_frames == 2
+40
View File
@@ -0,0 +1,40 @@
from src.config_loader import CrossingSettings
from src.crossing_detector import CrossingDetector
from src.models import BoundingBox, PixelTriggerLine, TrackPoint
def settings(**overrides: object) -> CrossingSettings:
values = dict(enabled=True, count_once_per_track=True, deadband_pixels=3,
minimum_displacement_pixels=10, minimum_track_points=3, require_confirmed_track=True)
values.update(overrides)
return CrossingSettings(**values) # type: ignore[arg-type]
def points(values: list[float], vertical: bool = True) -> list[TrackPoint]:
result = []
for frame, value in enumerate(values, 1):
x, y = (value, 20.0) if vertical else (20.0, value)
result.append(TrackPoint(float(frame), str(frame), frame, x, y, x, y, BoundingBox(0, 0, 2, 2)))
return result
def test_vertical_directions_and_no_crossing() -> None:
detector = CrossingDetector(settings(), PixelTriggerLine("vertical", 50))
assert detector.detect(1, points([30, 45, 60]), True) == "left_to_right"
assert detector.detect(2, points([70, 55, 40]), True) == "right_to_left"
assert detector.detect(3, points([20, 30, 40]), True) is None
def test_deadband_displacement_confirmation_and_once() -> None:
detector = CrossingDetector(settings(), PixelTriggerLine("vertical", 50))
assert detector.detect(1, points([46, 49, 54]), True) is None
assert CrossingDetector(settings(minimum_displacement_pixels=30), PixelTriggerLine("vertical", 50)).detect(2, points([40, 50, 60]), True) is None
assert detector.detect(4, points([30, 45, 60]), False) is None
assert detector.detect(4, points([30, 45, 60]), True) == "left_to_right"
assert detector.detect(4, points([60, 50, 30]), True) is None
def test_horizontal_directions() -> None:
detector = CrossingDetector(settings(), PixelTriggerLine("horizontal", 50))
assert detector.detect(1, points([30, 45, 60], vertical=False), True) == "top_to_bottom"
assert detector.detect(2, points([70, 55, 40], vertical=False), True) == "bottom_to_top"
+69
View File
@@ -0,0 +1,69 @@
import sys
from types import SimpleNamespace
from typing import Any
# The detector only needs NumPy for annotations; keep this unit test independent
# from binary packages and real image/model execution.
_inserted_numpy_stub = "numpy" not in sys.modules
if _inserted_numpy_stub:
sys.modules["numpy"] = SimpleNamespace(
ndarray=object,
isscalar=lambda value: isinstance(value, (int, float)),
)
from src.config_loader import DetectorSettings
from src.detector import PersonDetector
if _inserted_numpy_stub:
sys.modules.pop("numpy", None)
class FakeModel:
def __init__(self) -> None:
self.kwargs: dict[str, Any] = {}
def predict(self, **kwargs: Any) -> list[Any]:
self.kwargs = kwargs
return []
class FakeFrame:
shape = (10, 10, 3)
def make_settings(half_precision: bool) -> DetectorSettings:
return DetectorSettings(
model_path="unused.pt",
device="auto",
confidence_threshold=0.4,
iou_threshold=0.5,
image_size=640,
person_class_id=0,
max_detections=30,
half_precision=half_precision,
use_agnostic_nms=False,
)
def run_detection(half_precision: bool, effective_half: bool) -> dict[str, Any]:
detector = PersonDetector(make_settings(half_precision))
model = FakeModel()
detector._model = model
detector._device_name = "cuda" if effective_half else "cpu"
detector._half = effective_half
detector.detect(FakeFrame()) # type: ignore[arg-type]
return model.kwargs
def test_cpu_does_not_pass_half_when_disabled() -> None:
assert "half" not in run_detection(half_precision=False, effective_half=False)
def test_cpu_does_not_pass_half_when_requested_but_unavailable() -> None:
assert "half" not in run_detection(half_precision=True, effective_half=False)
def test_cuda_passes_half_only_when_effectively_enabled() -> None:
kwargs = run_detection(half_precision=True, effective_half=True)
assert kwargs["half"] is True
assert "quantize" not in kwargs
+6
View File
@@ -0,0 +1,6 @@
from pathlib import Path
from src.experiment_notes import ExperimentNotes
from src.models import ExperimentSessionNote
def test_note_utf8_roundtrip(tmp_path:Path)->None:
n=ExperimentSessionNote("n1",None,"e1","2026-08-09","prompt","学生","研究室","サイネージ正面",120.0,"水平","右側","左から右","昼",None,"こんにちは",.8,"config.yaml",None,None,10,None,"なし","日本語メモ")
store=ExperimentNotes();path=store.save(n,tmp_path/"note.yaml");loaded=store.load(path);assert loaded.camera_position_note=="サイネージ正面" and loaded.condition=="prompt"
+7
View File
@@ -0,0 +1,7 @@
from pathlib import Path
import yaml
from src.config_loader import load_config
from src.experiment_protocol import ExperimentProtocolBuilder
def test_protocol_exports(tmp_path:Path)->None:
c=load_config("config/default.yaml",None);b=ExperimentProtocolBuilder(c.to_dict());p=b.build_default_protocol();assert p.voice_volume==c.audio.volume and p.camera_position_description==c.unified_logging.camera_position_note
md=b.export_markdown(p,tmp_path/"p.md");yp=b.export_yaml(p,tmp_path/"p.yaml");assert "voice_volume" in md.read_text(encoding="utf-8");assert yaml.safe_load(yp.read_text(encoding="utf-8"))["privacy_note"]
+36
View File
@@ -0,0 +1,36 @@
from src.config_loader import FaceSettings
from src.face_geometry import create_face_roi
from src.models import BoundingBox, TrackedPerson
def settings(**overrides: object) -> FaceSettings:
values = dict(enabled=True, backend="mediapipe", process_every_n_frames=1, max_persons_per_frame=3,
require_confirmed_track=True, process_only_inside_region=False, roi_height_ratio=.5,
roi_expand_ratio=.1, min_roi_width=10, min_roi_height=10, static_image_mode=False,
max_num_faces=1, refine_landmarks=True, min_detection_confidence=.5,
min_tracking_confidence=.5)
values.update(overrides)
return FaceSettings(**values) # type: ignore[arg-type]
def person(bbox: BoundingBox, confirmed: bool = True, inside: bool = True) -> TrackedPerson:
return TrackedPerson(1, bbox, .9, 0, "person", bbox.center_x, bbox.center_y, bbox.foot_x, bbox.foot_y,
inside, confirmed, 3, 0)
def test_roi_ratio_expansion_and_clipping() -> None:
roi = create_face_roi(person(BoundingBox(10, 10, 110, 210)), 200, 300, settings())
assert roi is not None
assert (roi.x1, roi.x2) == (0, 120)
assert roi.y1 == 0
assert roi.y2 == 120
clipped = create_face_roi(person(BoundingBox(0, 0, 50, 100)), 60, 80, settings(roi_height_ratio=1.0))
assert clipped and clipped.x1 == 0 and clipped.y1 == 0 and clipped.y2 == 80
def test_small_unconfirmed_and_outside_are_excluded() -> None:
assert create_face_roi(person(BoundingBox(0, 0, 20, 20)), 100, 100,
settings(min_roi_width=40, min_roi_height=40)) is None
assert create_face_roi(person(BoundingBox(0, 0, 80, 100), confirmed=False), 100, 100, settings()) is None
assert create_face_roi(person(BoundingBox(0, 0, 80, 100), inside=False), 100, 100,
settings(process_only_inside_region=True)) is None
+32
View File
@@ -0,0 +1,32 @@
import csv
from pathlib import Path
from src.config_loader import FaceLoggingSettings
from src.face_logger import FaceLogger
from src.models import FaceTrackState, HeadPoseResult
def test_face_logs_success_failure_bom_and_summary_deduplication(tmp_path: Path) -> None:
logger = FaceLogger(FaceLoggingSettings(True, True, True, 1, str(tmp_path)), "exp", "20260101_000000")
logger.open()
success = HeadPoseResult(1, 1, "now", .1, True, True, 1.0, -2.0, 3.0, 468, None, .9)
failure = HeadPoseResult(1, 2, "later", .2, False, False, None, None, None, 0, None, None, "face_not_detected")
logger.write_results([success, failure])
state = FaceTrackState(1, total_frames=2, face_detected_frames=1, pose_estimated_frames=1,
last_pitch=1.0, last_yaw=-2.0, last_roll=3.0, min_yaw=-2.0,
max_yaw=-2.0, yaw_sum=-2.0, last_result=failure, finalized=True)
logger.write_summary(state)
logger.write_summary(state)
logger.close()
logger.close()
paths = list(tmp_path.glob("*.csv"))
assert len(paths) == 2
assert all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in paths)
frame_path = next(path for path in paths if "frames" in path.name)
with frame_path.open(encoding="utf-8-sig", newline="") as stream:
rows = list(csv.DictReader(stream))
assert len(rows) == 2 and rows[1]["failure_reason"] == "face_not_detected"
assert "yaw" in rows[0]
summary_path = next(path for path in paths if "summary" in path.name)
with summary_path.open(encoding="utf-8-sig", newline="") as stream:
assert len(list(csv.DictReader(stream))) == 1
+42
View File
@@ -0,0 +1,42 @@
from collections import deque
import pytest
from src.face_processor import FaceTrackManager
from src.models import BoundingBox, FaceROI, HeadPoseResult
def result(track_id: int, yaw: float | None, detected: bool = True) -> HeadPoseResult:
success = yaw is not None
return HeadPoseResult(track_id, 1, "now", .1, detected, success,
1.0 if success else None, yaw, 2.0 if success else None,
6 if detected else 0, None, .9 if detected else None,
"" if success else "face_not_detected")
def test_face_roi_properties() -> None:
bbox = BoundingBox(10, 20, 110, 220)
roi = FaceROI(1, 5, 10, 115, 100, bbox, .8)
assert (roi.width, roi.height) == (110, 90)
assert (roi.center_x, roi.center_y) == (60.0, 55.0)
def test_head_pose_result_success_and_failure_validation() -> None:
assert result(1, -12.0).pose_estimated
assert not result(1, None, False).face_detected
with pytest.raises(ValueError):
HeadPoseResult(1, 1, "now", 0, True, True, None, 1.0, 2.0, 6, None, .5)
def test_face_track_statistics_and_history_limit() -> None:
manager = FaceTrackManager(2)
manager.update([result(1, -10.0), result(1, 20.0), result(1, None, False)])
state = manager.get_state(1)
assert state is not None
assert state.total_frames == 3
assert state.face_detection_rate == pytest.approx(2 / 3)
assert state.pose_estimation_rate == pytest.approx(2 / 3)
assert (state.min_yaw, state.max_yaw, state.mean_yaw) == (-10.0, 20.0, 5.0)
assert len(state.history) == 2
finalized = manager.finalize_track(1)
assert finalized and finalized.finalized
+30
View File
@@ -0,0 +1,30 @@
import pytest
from src.fps_counter import FPSCounter
def test_initial_and_tick_and_average(monkeypatch: pytest.MonkeyPatch) -> None:
times = iter([1.0, 1.5, 1.75])
monkeypatch.setattr("src.fps_counter.time.perf_counter", lambda: next(times))
counter = FPSCounter(2)
assert counter.current_fps == counter.average_fps == 0.0
assert counter.tick() == 0.0
assert counter.tick() == pytest.approx(2.0)
assert counter.tick() == pytest.approx(4.0)
assert counter.average_fps == pytest.approx(3.0)
def test_reset(monkeypatch: pytest.MonkeyPatch) -> None:
times = iter([1.0, 2.0])
monkeypatch.setattr("src.fps_counter.time.perf_counter", lambda: next(times))
counter = FPSCounter(3)
counter.tick()
counter.tick()
counter.reset()
assert counter.current_fps == counter.average_fps == 0.0
assert counter.sample_count == 0
def test_invalid_window() -> None:
with pytest.raises(ValueError):
FPSCounter(0)
+17
View File
@@ -0,0 +1,17 @@
import time
from pathlib import Path
import pytest
from src.gui.analysis_controller import AnalysisController
from src.gui.gui_models import GuiAnalysisRequest
def test_analysis_runner_and_callback(tmp_path:Path)->None:
expected=[tmp_path/"condition_summary.csv"];finished=[]
c=AnalysisController(lambda settings,i,o:expected);c.set_finished_callback(finished.extend);c.run_analysis(GuiAnalysisRequest(output_directory=str(tmp_path)));time.sleep(.05)
assert finished==expected
def test_analysis_double_run_guard()->None:
def slow(s,i,o):time.sleep(.1);return []
c=AnalysisController(slow);c.run_analysis(GuiAnalysisRequest())
with pytest.raises(RuntimeError):c.run_analysis(GuiAnalysisRequest())
def test_analysis_error_callback()->None:
errors=[]
def fail(s,i,o):raise RuntimeError("broken")
c=AnalysisController(fail);c.set_error_callback(errors.append);c.run_analysis(GuiAnalysisRequest());time.sleep(.05);assert errors==["broken"]
+16
View File
@@ -0,0 +1,16 @@
from pathlib import Path
from src.gui.config_panel import experiment_overrides,validate_experiment_request
from src.gui.gui_models import GuiExperimentRequest
CONFIG=str(Path(__file__).parents[1]/"config"/"experiment.yaml")
def request(**values):return GuiExperimentRequest(config_path=CONFIG,**values)
def test_invalid_numeric_fields()->None:
assert not validate_experiment_request(request(camera_index=-1)).valid
assert not validate_experiment_request(request(voice_volume=1.1)).valid
assert not validate_experiment_request(request(face_every=0)).valid
assert not validate_experiment_request(request(max_face_persons=0)).valid
def test_video_requires_existing_path()->None:assert not validate_experiment_request(request(input_type="video")).valid
def test_prompt_without_audio_is_warning()->None:
result=validate_experiment_request(request(voice_mode="prompt"));assert result.valid and result.warnings
def test_valid_request_and_overrides()->None:
r=request(voice_mode="control",signage_yaw_direction="negative");assert validate_experiment_request(r).valid
values=experiment_overrides(r);assert values["voice_prompt"]["mode"]=="control" and values["turn_detection"]["signage_yaw_direction"]=="negative"
+21
View File
@@ -0,0 +1,21 @@
import time
from pathlib import Path
import pytest
from src.gui.experiment_controller import ExperimentController
from src.gui.gui_models import GuiExperimentRequest
CONFIG=str(Path(__file__).parents[1]/"config"/"experiment.yaml")
class FakeApp:
instances=[]
def __init__(self,config,**kwargs):self.stop=False;self.paused=False;self.kwargs=kwargs;FakeApp.instances.append(self)
def run(self):
while not self.stop:time.sleep(.005)
def request_stop(self):self.stop=True
def request_pause(self):self.paused=True
def request_resume(self):self.paused=False
def test_controller_start_stop_pause_without_camera()->None:
c=ExperimentController(FakeApp);c.start_experiment(GuiExperimentRequest(config_path=CONFIG));time.sleep(.02)
assert c.is_running();c.pause_experiment();assert FakeApp.instances[-1].paused;c.resume_experiment();assert not FakeApp.instances[-1].paused;c.stop_experiment();time.sleep(.03);assert not c.is_running()
def test_controller_prevents_double_start()->None:
c=ExperimentController(FakeApp);c.start_experiment(GuiExperimentRequest(config_path=CONFIG));
with pytest.raises(RuntimeError):c.start_experiment(GuiExperimentRequest(config_path=CONFIG))
c.stop_experiment()
+8
View File
@@ -0,0 +1,8 @@
from src.gui.gui_models import GuiAnalysisRequest,GuiExperimentRequest
def test_gui_request_defaults()->None:
e=GuiExperimentRequest();a=GuiAnalysisRequest()
assert e.input_type=="camera" and e.voice_mode=="disabled" and e.camera_position_note==""
assert a.recursive and a.response_test=="auto"
def test_gui_request_preserves_values()->None:
e=GuiExperimentRequest(audio_file="x.wav",camera_position_note="front",voice_mode="prompt")
assert e.audio_file=="x.wav" and e.camera_position_note=="front"
+52
View File
@@ -0,0 +1,52 @@
import math
from src.config_loader import HeadPoseSettings
from src.head_pose import HeadPoseEstimator
from src.models import FaceLandmark
def settings(max_angle: float = 90.0) -> HeadPoseSettings:
return HeadPoseSettings(True, "solvepnp", True, "ema", .4, max_angle)
def landmarks() -> list[FaceLandmark]:
return [FaceLandmark(index, float(index), float(index + 1), 0.0)
for index in HeadPoseEstimator.LANDMARK_INDICES]
def test_missing_landmarks_and_input_builders() -> None:
estimator = HeadPoseEstimator(settings())
assert estimator.estimate([], 640, 480).failure_reason == "insufficient_landmarks"
assert len(estimator.build_image_points(landmarks()) or []) == 6
matrix = estimator.build_camera_matrix(640, 480)
assert matrix == [[640.0, 0.0, 320.0], [0.0, 640.0, 240.0], [0.0, 0.0, 1.0]]
def test_euler_conversion_returns_floats() -> None:
angles = HeadPoseEstimator.rotation_matrix_to_euler([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])
assert all(isinstance(value, float) for value in angles)
assert angles == (0.0, -0.0, 0.0)
def test_angle_range_validation() -> None:
estimator = HeadPoseEstimator(settings(45.0))
assert estimator.result_from_angles((10.0, 20.0, 5.0)).success
failed = estimator.result_from_angles((10.0, 60.0, 5.0))
assert failed.failure_reason == "angle_out_of_range"
assert (failed.pitch, failed.yaw, failed.roll) == (10.0, 60.0, 5.0)
assert estimator.result_from_angles((math.nan, 0.0, 0.0)).failure_reason == "angle_out_of_range"
def test_face_model_uses_image_coordinate_y_direction() -> None:
_, chin, left_eye, right_eye, left_mouth, right_mouth = HeadPoseEstimator.MODEL_POINTS
assert chin[1] > 0
assert left_eye[1] < 0 and right_eye[1] < 0
assert left_mouth[1] > 0 and right_mouth[1] > 0
def test_roll_near_180_is_canonicalized_for_upright_face() -> None:
estimator = HeadPoseEstimator(settings())
assert estimator.normalize_angles((2.0, -5.0, -170.0)) == (2.0, -5.0, 10.0)
assert estimator.normalize_angles((2.0, -5.0, 170.0)) == (2.0, -5.0, -10.0)
result = estimator.result_from_angles((2.0, -5.0, -170.0))
assert result.success and result.roll == 10.0
+4
View File
@@ -0,0 +1,4 @@
from src.improvement_recommender import ImprovementRecommender
from src.models import ConditionBalanceDiagnostic
def test_recommendation_categories()->None:
b=ConditionBalanceDiagnostic(10,2,8,1,.5,.2,7,8.,False,"bad","add");cats={x.category for x in ImprovementRecommender().recommend(.3,.6,.7,b,8,1,True)};assert {"camera_position","face_detection","condition_balance","sample_size"}<=cats
+8
View File
@@ -0,0 +1,8 @@
from src.log_consistency_checker import LogConsistencyChecker
from src.models import PersonAnalysisRecord
def test_detects_nonfatal_issues() -> None:
r=PersonAnalysisRecord("e","s",1,"prompt","unknown",response_detected=True,reaction_time_sec=-1.,excluded=True,exclusion_reason="none",valid_for_voice_analysis=True)
kinds={x.issue_type for x in LogConsistencyChecker().check([r,r])}
assert {"missing_prompt_for_response","invalid_reaction_time","response_without_turn","duplicate_summary"}<=kinds
+24
View File
@@ -0,0 +1,24 @@
import pytest
from src.models import BoundingBox, PixelRegion
def test_bounding_box_properties() -> None:
box = BoundingBox(10, 20, 30, 50)
assert (box.width, box.height) == (20, 30)
assert (box.center_x, box.center_y) == (20, 35)
assert (box.foot_x, box.foot_y) == (20, 50)
assert box.area == 600
def test_pixel_region_contains_including_boundary() -> None:
region = PixelRegion(10, 20, 30, 40)
assert region.contains(10, 20)
assert region.contains(30, 40)
assert not region.contains(31, 40)
@pytest.mark.parametrize("coords", [(1, 0, 1, 2), (0, 2, 1, 2), (2, 0, 1, 2)])
def test_invalid_bounding_box(coords: tuple[int, int, int, int]) -> None:
with pytest.raises(ValueError):
BoundingBox(*coords)
+19
View File
@@ -0,0 +1,19 @@
import csv
from pathlib import Path
from src.config_loader import PersonStateLoggingSettings
from src.models import PersonStateEvent, PersonStateSnapshot, PersonStateSummary
from src.person_state_logger import PersonStateLogger
def test_person_state_csvs_bom_json_and_dedup(tmp_path: Path) -> None:
logger = PersonStateLogger(PersonStateLoggingSettings(True, True, True, True, 1, str(tmp_path)), "exp", "stamp")
logger.open()
logger.write_snapshots([PersonStateSnapshot(1, 1, "t", 1., "tracked", metadata={"日本語": "状態"})])
logger.write_events([PersonStateEvent("status_changed", 1, 1, "t", 1., "new", "tracked", metadata={"理由": "追跡"})])
summary = PersonStateSummary("exp", 1, final_status="completed", completed=True)
logger.write_summary(summary); logger.write_summary(summary); logger.close(); logger.close()
assert all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in tmp_path.glob("*.csv"))
assert "日本語" in (tmp_path / "stamp_exp_person_state_frames.csv").read_text(encoding="utf-8-sig")
with (tmp_path / "stamp_exp_person_state_summary.csv").open(encoding="utf-8-sig", newline="") as stream:
assert len(list(csv.DictReader(stream))) == 1
+56
View File
@@ -0,0 +1,56 @@
from src.config_loader import PersonStateSettings
from src.models import (BoundingBox, FrameInfo, HeadPoseResult, TrackEvent, TrackedPerson, TurnFrameResult,
VoicePromptDecision, VoicePromptEvent, VoicePromptTrackState)
from src.person_state_manager import PersonStateManager
CFG = PersonStateSettings(True, 0., False, False, False, True, True)
def info(frame: int, elapsed: float) -> FrameInfo:
return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 30.)
def person() -> TrackedPerson:
return TrackedPerson(1, BoundingBox(1, 1, 20, 40), .9, 0, "person", 10, 20, 10, 40, True, True, 10, 0)
def face() -> HeadPoseResult:
return HeadPoseResult(1, 1, "t1", 1., True, True, 0., 10., 0., 6, None, .9)
def turn() -> TurnFrameResult:
return TurnFrameResult(1, 1, "t1", 1., True, True, True, 0., 25., 25., 25., "positive", True, True, "weak", .4)
def test_integrates_track_face_turn_voice_and_response() -> None:
manager = PersonStateManager(CFG, "exp")
track_events = [TrackEvent("region_entered", 1, 1, "t1", 1., 10, 40),
TrackEvent("trigger_crossed", 1, 1, "t1", 1., 10, 40, "left_to_right")]
decision = VoicePromptDecision(1, 1, "t1", 1., "prompt", True, True, True, False, "prompt_played", "a.wav")
voice_events = [VoicePromptEvent("response_window_started", 1, 1, "t1", 1., "prompt", True),
VoicePromptEvent("response_detected", 1, 1, "t1", 1.5, "prompt", True,
turn_confirmed=True, turn_level="weak", reaction_time_sec=.5)]
voice_state = VoicePromptTrackState(1, "prompt", eligible=True, prompt_played=True,
response_window_started=True, response_detected=True,
reaction_time_sec=.5, response_turn_level="weak")
snapshots, events = manager.update([person()], track_events, [face()], [turn()], None,
[decision], voice_events, {1: voice_state}, info(1, 1.5))
state = snapshots[0]
assert state.status == "responded" and state.trigger_crossed
assert state.face_detected and state.pose_estimated and state.turn_confirmed
assert state.prompt_played and state.response_detected and state.reaction_time_sec == .5
assert any(event.event_type == "status_changed" for event in events)
summary, _ = manager.finalize_track(1, info(2, 2.), voice_state=voice_state)
assert summary and summary.trigger_crossed and summary.response_detected
def test_control_no_response_and_finalize_all() -> None:
manager = PersonStateManager(CFG, "exp")
decision = VoicePromptDecision(1, 1, "t1", 1., "control", True, True, False, True, "pseudo")
events = [VoicePromptEvent("response_window_expired", 1, 2, "t2", 2., "control", pseudo_prompt=True)]
snapshots, _ = manager.update([person()], [], [], [], None, [decision], events, None, info(2, 2.))
assert snapshots[0].status == "no_response" and snapshots[0].pseudo_prompt
summaries, _ = manager.finalize_all(info(3, 3.))
assert len(summaries) == 1
+13
View File
@@ -0,0 +1,13 @@
from src.models import (PersonExclusionReason, PersonExperimentStatus, PersonStateEvent,
PersonStateSnapshot, PersonStateSummary)
def test_enums_and_models() -> None:
assert PersonExperimentStatus.RESPONDED.value == "responded"
assert PersonExclusionReason.NO_FACE_DETECTED.value == "no_face_detected"
snapshot = PersonStateSnapshot(1, 2, "t", 1., "tracked", metadata={"日本語": True})
event = PersonStateEvent("status_changed", 1, 2, "t", 1., "new", "tracked")
summary = PersonStateSummary("exp", 1)
assert snapshot.metadata["日本語"] and event.new_status == "tracked"
assert summary.completed is False and summary.exclusion_reason == "none"
+27
View File
@@ -0,0 +1,27 @@
from src.config_loader import PersonStateSettings
from src.person_state_rules import choose_status, final_exclusion_reason, normalize_exclusion_reason
CFG = PersonStateSettings(True, .5, False, True, False, True, True)
def test_status_priority_chain() -> None:
assert choose_status(tracked=True) == "tracked"
assert choose_status(tracked=True, inside_region=True) == "inside_region"
assert choose_status(eligible=True) == "eligible"
assert choose_status(prompt_played=True) == "prompted"
assert choose_status(pseudo_prompt=True) == "pseudo_prompted"
assert choose_status(observing=True) == "observing_response"
assert choose_status(responded=True, lost=True) == "lost"
assert choose_status(responded=True) == "responded"
assert choose_status(no_response=True) == "no_response"
assert choose_status(finalized=True) == "completed"
assert choose_status(excluded=True, finalized=True) == "excluded"
def test_exclusion_rules() -> None:
assert final_exclusion_reason(CFG, .1, True, True, None, None, False, True, False) == "track_too_short"
assert final_exclusion_reason(CFG, 1., True, False, None, None, False, False, False) == "not_prompt_eligible"
assert final_exclusion_reason(CFG, 1., True, True, None, "prompt_failed", False, True, False) == "prompt_failed"
assert normalize_exclusion_reason("no_turn_state") == "response_not_evaluable"
+9
View File
@@ -0,0 +1,9 @@
from pathlib import Path
from main import build_phase11_parser
from src.config_loader import load_config
from src.phase11_cli import run_diagnostics
def test_phase11_parsers()->None:
assert build_phase11_parser("pilot-diagnostics").parse_args([]).output==Path("data/analysis")
assert build_phase11_parser("calibration-add").parse_args(["--category","camera_position","--before","a","--after","b","--reason","r"]).category=="camera_position"
def test_diagnostics_runner_is_camera_free(tmp_path:Path)->None:
outputs=run_diagnostics(load_config("config/default.yaml",None),tmp_path,tmp_path/"logs",tmp_path/"out");assert len(outputs)==4
+20
View File
@@ -0,0 +1,20 @@
import csv
from pathlib import Path
from src.config_loader import load_config
from src.pilot_diagnostics import PilotDiagnostics
FIELDS=["session_id","prompt_condition","valid_for_voice_analysis","response_detected","turn_level","pose_estimated_ever","camera_position_note","reaction_time_sec"]
def write(root:Path,rows):
root.mkdir();p=root/"x_person_analysis_table.csv"
with p.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=FIELDS);w.writeheader();w.writerows(rows)
def test_empty_is_insufficient_and_exports(tmp_path:Path)->None:
d=PilotDiagnostics(load_config("config/default.yaml",None).pilot_diagnostics);r=d.run(tmp_path,tmp_path/"none");assert r.overall_status=="insufficient_data";paths=d.export(r,tmp_path/"out");assert len(paths)==4 and paths[1].read_bytes().startswith(b"\xef\xbb\xbf")
def test_ready_with_target_counts(tmp_path:Path)->None:
rows=[]
for cond in ("prompt","control"):
for i in range(20):rows.append({"session_id":cond,"prompt_condition":cond,"valid_for_voice_analysis":"true","response_detected":"false","turn_level":"none","pose_estimated_ever":"true","camera_position_note":"front","reaction_time_sec":""})
logs=tmp_path/"logs";write(logs,rows);r=PilotDiagnostics(load_config("config/default.yaml",None).pilot_diagnostics).run(tmp_path,logs);assert r.overall_status=="ready" and r.ready_for_main_experiment
def test_high_not_evaluable_needs_major(tmp_path:Path)->None:
rows=[]
for cond in ("prompt","control"):
for i in range(5):rows.append({"session_id":cond,"prompt_condition":cond,"valid_for_voice_analysis":"true","response_detected":"false","turn_level":"not_evaluable","pose_estimated_ever":"false","camera_position_note":"front","reaction_time_sec":""})
logs=tmp_path/"logs";write(logs,rows);assert PilotDiagnostics(load_config("config/default.yaml",None).pilot_diagnostics).run(tmp_path,logs).overall_status=="needs_major_adjustment"
+8
View File
@@ -0,0 +1,8 @@
import csv
from pathlib import Path
from src.pilot_session_reporter import PilotSessionReporter
def test_report_reads_phase8_and_exports(tmp_path:Path)->None:
fields=["condition","total_records","valid_voice_analysis_count","response_rate","mean_reaction_time_sec"]
with (tmp_path/"condition_summary.csv").open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows([{"condition":"prompt","total_records":10,"valid_voice_analysis_count":8,"response_rate":.5,"mean_reaction_time_sec":1.2},{"condition":"control","total_records":10,"valid_voice_analysis_count":7,"response_rate":.2,"mean_reaction_time_sec":1.4}])
r=PilotSessionReporter();report=r.build_report(tmp_path);assert report.rate_difference==.3;assert "因果関係" in r.export_markdown(report,tmp_path/"pilot.md").read_text(encoding="utf-8")
def test_empty_report_is_generated(tmp_path:Path)->None:assert PilotSessionReporter().build_report(tmp_path).total_tracks==0
@@ -0,0 +1,5 @@
from pathlib import Path
from src.models import PilotSessionReport
from src.presentation_summary_builder import PresentationSummaryBuilder
def test_midterm_summary_contains_required_sections(tmp_path:Path)->None:
r=PilotSessionReport("r","now","in","out",0,0,0,0,0,None,None,None,None,None,None,"find","quality","next");path=PresentationSummaryBuilder().build(r,tmp_path,tmp_path/"midterm.md");text=path.read_text(encoding="utf-8");assert "研究目的" in text and "実装済み" in text and "反応率" in text and "因果関係" in text
+12
View File
@@ -0,0 +1,12 @@
import csv
from pathlib import Path
from src.config_loader import load_config
from src.quality_checker import QualityChecker
def write_table(path:Path,rows:list[dict])->None:
path.parent.mkdir(parents=True);fields=["prompt_condition","valid_for_voice_analysis","turn_level","pose_estimated_ever","camera_position_note","reaction_time_sec","response_detected"]
with path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows(rows)
def test_missing_files_and_exports(tmp_path:Path)->None:
q=QualityChecker(load_config("config/default.yaml",None).quality_check);issues=q.check(tmp_path/"logs",tmp_path/"analysis");assert any(i.issue_type=="missing_file" for i in issues)
assert q.export_csv(issues,tmp_path/"quality_issues.csv").read_bytes().startswith(b"\xef\xbb\xbf");assert "ログ品質" in q.export_markdown(issues,tmp_path/"quality.md").read_text(encoding="utf-8")
def test_quality_rates_conditions_and_negative_time(tmp_path:Path)->None:
table=tmp_path/"logs"/"x_person_analysis_table.csv";write_table(table,[{"prompt_condition":"prompt","valid_for_voice_analysis":"false","turn_level":"not_evaluable","pose_estimated_ever":"false","camera_position_note":"","reaction_time_sec":"-1","response_detected":"true"}]);q=QualityChecker(load_config("config/default.yaml",None).quality_check);types={i.issue_type for i in q.check(tmp_path/"logs",tmp_path/"analysis")};assert {"no_control_records","low_valid_voice_analysis_rate","high_not_evaluable_rate","high_no_pose_rate","missing_camera_position_note","negative_reaction_time"}<=types
+3
View File
@@ -0,0 +1,3 @@
from src.session_comparator import SessionComparator
def test_session_rates_and_camera_missing()->None:
rows=[{"session_id":"s","prompt_condition":"prompt","valid_for_voice_analysis":"true","response_detected":"true","turn_level":"weak","pose_estimated_ever":"true","reaction_time_sec":"1","camera_position_note":"front"},{"session_id":"s","prompt_condition":"prompt","valid_for_voice_analysis":"false","response_detected":"false","turn_level":"not_evaluable","pose_estimated_ever":"false"}];s=SessionComparator().summarize(rows)[0];assert s.valid_voice_analysis_rate==.5 and s.not_evaluable_rate==.5 and s.no_pose_estimated_rate==.5
+16
View File
@@ -0,0 +1,16 @@
from src.config_loader import UnifiedLogSettings
from src.models import PersonAnalysisRecord
from src.session_summary_builder import SessionSummaryBuilder
CFG=UnifiedLogSettings(True,"x",True,True,True,True,30,True,True,True,True,False,False,"cam",True)
def test_rates_exclude_invalid() -> None:
records=[PersonAnalysisRecord("e","s",1,"prompt","prompt",True,response_detected=True,reaction_time_sec=1.,turn_confirmed=True,turn_level="medium",valid_for_turn_analysis=True,valid_for_voice_analysis=True,completed=True),
PersonAnalysisRecord("e","s",2,"prompt","prompt",True,response_detected=False,turn_level="subtle",valid_for_turn_analysis=False,valid_for_voice_analysis=False,excluded=True)]
out=SessionSummaryBuilder(CFG,"e","s","start").build(records,"end",10.,"prompt",20.)
assert out.total_tracks==2 and out.valid_voice_analysis_tracks==1 and out.response_rate==1.
assert out.mean_reaction_time_sec==out.median_reaction_time_sec==1.
assert out.not_evaluable_count==1 and out.medium_or_higher_count==1 and out.subtle_count==1
def test_zero_denominator() -> None:
out=SessionSummaryBuilder(CFG,"e","s","start").build([],"end",0.,None)
assert out.response_rate is None
+11
View File
@@ -0,0 +1,11 @@
import pytest
pd=pytest.importorskip("pandas")
from src.config_loader import AnalysisSettings
from src.statistical_analyzer import StatisticalAnalyzer
CFG=AnalysisSettings(True,"in","out",True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",False,True,.05)
def test_valid_denominator_summaries_and_run()->None:
df=pd.DataFrame([{"prompt_condition":"prompt","valid_for_voice_analysis":True,"response_detected":True,"reaction_time_sec":1.,"max_yaw_delta_toward_signage":30.,"turn_level":"weak","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"},{"prompt_condition":"prompt","valid_for_voice_analysis":False,"response_detected":False,"reaction_time_sec":None,"max_yaw_delta_toward_signage":None,"turn_level":None,"excluded":True,"valid_for_turn_analysis":False,"exclusion_reason":"no_pose_estimated"},{"prompt_condition":"control","valid_for_voice_analysis":True,"response_detected":False,"reaction_time_sec":None,"max_yaw_delta_toward_signage":5.,"turn_level":"none","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"}])
a=StatisticalAnalyzer(CFG);s=a.summarize_conditions(df);assert s[0].valid_voice_analysis_count==1 and s[0].response_rate==1 and s[1].response_rate==0
result=a.run_analysis(df,1);assert result.run_summary.total_records==3 and result.exclusion_summaries[0].count==1
+11
View File
@@ -0,0 +1,11 @@
from src.statistical_tests import compare_numeric,compare_response_counts
def test_response_auto_fisher_and_rates() -> None:
r=compare_response_counts(1,3,0,3,"auto");assert r.test_name=="fisher" and r.rate_difference==1/3
def test_response_auto_chi_and_phi() -> None:
r=compare_response_counts(30,50,15,50,"auto");assert r.test_name=="chi_square" and r.p_value is not None and r.effect_size_name=="phi"
def test_numeric_and_empty() -> None:
r=compare_numeric([1.,2.,3.],[3.,4.,5.],"reaction_time_sec");assert r.prompt_count==3 and r.effect_size_name=="rank_biserial"
assert compare_numeric([],[],"yaw").p_value is None
assert compare_response_counts(0,0,0,0).p_value is None
+94
View File
@@ -0,0 +1,94 @@
from src.config_loader import CrossingSettings, TrackManagementSettings
from src.models import BoundingBox, FrameInfo, PixelRegion, PixelTriggerLine, TrackedPerson
from src.track_manager import TrackManager
def management(**overrides: object) -> TrackManagementSettings:
values = dict(trajectory_max_points=20, direction_window_points=5, minimum_direction_displacement_pixels=5,
region_entry_confirm_frames=2, region_exit_confirm_frames=3,
minimum_visible_frames_for_count=3, minimum_duration_sec_for_count=0.1,
require_region_entry_for_count=True, require_trigger_crossing_for_count=True)
values.update(overrides)
return TrackManagementSettings(**values) # type: ignore[arg-type]
def crossing() -> CrossingSettings:
return CrossingSettings(True, True, 2, 5, 3, True)
def info(frame: int) -> FrameInfo:
return FrameInfo(frame, f"2026-01-01T00:00:{frame:02d}", frame * 0.1, 100, 100, 20.0)
def person(track_id: int, frame: int, x: int, y: int, confirmed: bool = True) -> TrackedPerson:
bbox = BoundingBox(max(0, x - 5), max(0, y - 20), min(99, x + 5), min(99, y))
return TrackedPerson(track_id, bbox, 0.9, 0, "person", x, y - 10, x, y, False, confirmed, frame, 0)
def manager(**overrides: object) -> TrackManager:
return TrackManager(management(**overrides), PixelRegion(20, 20, 80, 80), PixelTriggerLine("vertical", 50), crossing(), 2)
def test_create_initial_entry_trajectory_and_unique_count() -> None:
value = manager()
events = value.update([person(1, 1, 30, 60)], info(1))
assert [event.event_type for event in events] == ["track_created", "region_entered"]
value.update([person(1, 2, 40, 60)], info(2))
assert len(value.get_state(1).trajectory) == 2 # type: ignore[union-attr]
assert value.total_unique_tracks == value.region_visitors == 1
def test_entry_exit_hysteresis_and_no_duplicates() -> None:
value = manager()
value.update([person(1, 1, 10, 60)], info(1))
assert not value.update([person(1, 2, 30, 60)], info(2))
assert [e.event_type for e in value.update([person(1, 3, 35, 60)], info(3))] == ["region_entered"]
assert "region_exited" not in [e.event_type for e in value.update([person(1, 4, 90, 60)], info(4))]
assert "region_exited" not in [e.event_type for e in value.update([person(1, 5, 90, 60)], info(5))]
assert "region_exited" in [e.event_type for e in value.update([person(1, 6, 90, 60)], info(6))]
assert "region_exited" not in [e.event_type for e in value.update([person(1, 7, 90, 60)], info(7))]
def test_crossing_direction_finalization_and_valid_count() -> None:
value = manager()
all_events = []
for frame, x in enumerate((30, 45, 60), 1):
all_events.extend(value.update([person(1, frame, x, 60)], info(frame)))
assert sum(event.event_type == "trigger_crossed" for event in all_events) == 1
state = value.get_state(1)
assert state and state.movement_direction == "left_to_right"
events = value.finalize_all(info(4))
assert events[0].event_type == "track_finalized"
assert value.get_finalized_states()[0].valid_for_counting
assert value.completed_passers == 1
def test_stale_track_and_short_track_invalid() -> None:
value = manager(minimum_visible_frames_for_count=5)
value.update([person(1, 1, 30, 60)], info(1))
value.update([], info(2))
value.update([], info(3))
value.update([], info(4))
events = value.finalize_stale_tracks(info(4))
assert [event.event_type for event in events] == ["track_lost", "track_finalized"]
assert value.get_state(1).invalid_reason == "insufficient_visible_frames" # type: ignore[union-attr]
def test_crossing_direction_wins_when_track_stops_before_finalization() -> None:
"""Reproduce a right-to-left crossing followed by stationary tail points."""
value = manager(direction_window_points=3, minimum_direction_displacement_pixels=5)
all_events = []
positions = (70, 55, 40, 40, 40, 40)
for frame, x in enumerate(positions, 1):
all_events.extend(value.update([person(1, frame, x, 60)], info(frame)))
crossing_event = next(event for event in all_events if event.event_type == "trigger_crossed")
assert crossing_event.direction == "right_to_left"
state = value.get_state(1)
assert state and state.trigger_crossing_direction == "right_to_left"
assert state.movement_direction == "right_to_left"
finalized_event = value.finalize_all(info(7))[0]
finalized_state = value.get_finalized_states()[0]
assert finalized_event.direction == "right_to_left"
assert finalized_state.movement_direction == "right_to_left"
+58
View File
@@ -0,0 +1,58 @@
import sys
from argparse import Namespace
from types import SimpleNamespace
_inserted_numpy_stub = "numpy" not in sys.modules
if _inserted_numpy_stub:
sys.modules["numpy"] = SimpleNamespace(ndarray=object)
from src.config_loader import TrackerSettings
from src.tracker import PersonTracker
if _inserted_numpy_stub:
sys.modules.pop("numpy", None)
def settings() -> TrackerSettings:
return TrackerSettings(False, "bytetrack", .5, .1, .6, .8, 30, 2, 30, True)
def test_external_rows_are_normalized_and_ids_are_stable() -> None:
tracker = PersonTracker(settings())
rows = [[-2, 1, 20, 30, 7, .9, 0, 0]]
first = tracker._normalize(rows, 100, 100)
second = tracker._normalize(rows, 100, 100)
assert first[0].track_id == second[0].track_id
assert first[0].bbox.x1 == 0
assert not first[0].is_confirmed
assert second[0].is_confirmed
def test_empty_non_person_and_invalid_boxes_are_filtered() -> None:
tracker = PersonTracker(settings())
assert tracker._normalize([], 100, 100) == []
rows = [[1, 1, 1, 20, 1, .9, 0, 0], [1, 1, 20, 20, 2, .9, 3, 0]]
assert tracker._normalize(rows, 100, 100) == []
def test_reset_does_not_reuse_public_ids() -> None:
tracker = PersonTracker(settings())
first = tracker._normalize([[1, 1, 20, 20, 1, .9, 0, 0]], 100, 100)[0].track_id
tracker.reset()
second = tracker._normalize([[1, 1, 20, 20, 1, .9, 0, 0]], 100, 100)[0].track_id
assert second > first
def test_backend_constructor_supports_new_and_old_ultralytics_apis() -> None:
class NewBackend:
def __init__(self, args: Namespace) -> None:
self.args = args
class OldBackend:
def __init__(self, args: Namespace, frame_rate: int) -> None:
self.args, self.frame_rate = args, frame_rate
args = Namespace(track_buffer=30)
assert PersonTracker._create_backend(NewBackend, args, 25).args is args
old = PersonTracker._create_backend(OldBackend, args, 25)
assert old.args is args and old.frame_rate == 25
+33
View File
@@ -0,0 +1,33 @@
from collections import deque
import pytest
from src.models import BoundingBox, TrackEvent, TrackPoint, TrackedPerson, TrackState
def point(frame: int, x: float) -> TrackPoint:
return TrackPoint(float(frame), f"2026-01-01T00:00:0{frame}", frame, x, 10.0, x, 20.0, BoundingBox(0, 0, 10, 20))
def test_tracked_person_and_track_point() -> None:
bbox = BoundingBox(1, 2, 11, 22)
person = TrackedPerson(3, bbox, 0.9, 0, "person", 6.0, 12.0, 6.0, 22.0, True, True, 4, 0)
assert person.track_id == 3
assert person.foot_y == 22.0
assert point(1, 5).frame_number == 1
def test_track_state_trajectory_is_bounded() -> None:
state = TrackState(1, "a", "a", 1, 1, trajectory=deque(maxlen=2))
state.trajectory.extend((point(1, 1), point(2, 2), point(3, 3)))
assert [item.frame_number for item in state.trajectory] == [2, 3]
assert not state.finalized
def test_track_event_and_invalid_values() -> None:
event = TrackEvent("track_created", 1, 1, "now", 0.0, 1.0, 2.0, metadata={"日本語": "値"})
assert event.metadata["日本語"] == "値"
with pytest.raises(ValueError):
TrackEvent("bad", 1, 1, "now", 0.0, 0.0, 0.0)
with pytest.raises(ValueError):
TrackedPerson(-1, BoundingBox(0, 0, 2, 2), 0.5, 0, "person", 1, 1, 1, 2, False, False, 0, 0)
+38
View File
@@ -0,0 +1,38 @@
import csv
import json
from collections import deque
from pathlib import Path
from src.config_loader import TrackingLoggingSettings
from src.models import BoundingBox, FrameInfo, TrackEvent, TrackPoint, TrackedPerson, TrackState
from src.tracking_logger import TrackingLogger
def test_all_tracking_csv_logs_and_duplicate_summary(tmp_path: Path) -> None:
config = TrackingLoggingSettings(True, True, True, True, 1, str(tmp_path))
logger = TrackingLogger(config, "exp", "20260101_000000")
logger.open()
bbox = BoundingBox(1, 2, 11, 22)
tracked = TrackedPerson(1, bbox, .9, 0, "person", 6, 12, 6, 22, True, True, 3, 0)
point = TrackPoint(.1, "now", 1, 6, 12, 6, 22, bbox)
state = TrackState(1, "now", "later", 1, 2, entered_evaluation_region=True, trigger_crossed=True,
movement_direction="left_to_right", total_visible_frames=3, trajectory=deque([point]),
finalized=True, is_confirmed=True, first_seen_monotonic=.1, last_seen_monotonic=.5,
valid_for_counting=True, invalid_reason="")
logger.write_frame_tracks(FrameInfo(1, "now", .1, 100, 100, 20), [tracked], {1: state})
event = TrackEvent("trigger_crossed", 1, 1, "now", .1, 6, 22, "left_to_right", {"注記": "通過"})
logger.write_events([event])
logger.write_summary(state)
logger.write_summary(state)
logger.close()
logger.close()
paths = list(tmp_path.glob("*.csv"))
assert len(paths) == 3
assert all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in paths)
summary = next(path for path in paths if "summary" in path.name)
with summary.open(encoding="utf-8-sig", newline="") as stream:
assert len(list(csv.DictReader(stream))) == 1
events = next(path for path in paths if "events" in path.name)
with events.open(encoding="utf-8-sig", newline="") as stream:
row = next(csv.DictReader(stream))
assert json.loads(row["metadata_json"])["注記"] == "通過"
+99
View File
@@ -0,0 +1,99 @@
from src.config_loader import TurnDetectionSettings
from src.models import FrameInfo, HeadPoseResult
from src.turn_detector import TurnDetector
def config(**overrides: object) -> TurnDetectionSettings:
values = dict(enabled=True, signage_yaw_direction="positive", baseline_strategy="first_valid",
baseline_window_sec=.8, baseline_min_samples=3, subtle_threshold_deg=10,
weak_threshold_deg=20, medium_threshold_deg=35, strong_threshold_deg=50,
min_subtle_duration_sec=.2, min_turn_duration_sec=.3, max_pose_gap_sec=.3,
min_pose_samples_for_evaluation=5, min_face_detection_rate_for_evaluation=.2,
min_pose_estimation_rate_for_evaluation=.2, face_appearance_enabled=True,
face_missing_before_appearance_sec=.3, weak_or_higher_is_turn=True)
values.update(overrides)
return TurnDetectionSettings(**values) # type: ignore[arg-type]
def info(frame: int, elapsed: float) -> FrameInfo:
return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 20)
def pose(frame: int, elapsed: float, yaw: float | None, face: bool = True) -> HeadPoseResult:
success = yaw is not None
return HeadPoseResult(1, frame, f"t{frame}", elapsed, face, success,
0 if success else None, yaw, 0 if success else None,
6 if face else 0, None, .9 if face else None,
"" if success else ("solvepnp_failed" if face else "face_not_detected"))
def feed(detector: TurnDetector, samples: list[tuple[float, float | None, bool]]) -> tuple[list, list]:
results, events = [], []
for frame, (elapsed, yaw, face) in enumerate(samples, 1):
frame_results, new_events = detector.update([pose(frame, elapsed, yaw, face)], info(frame, elapsed))
results.extend(frame_results)
events.extend(new_events)
return results, events
def test_first_valid_baseline_and_threshold_levels() -> None:
detector = TurnDetector(config())
results, events = feed(detector, [(0, 0, True), (.1, 2, True), (.2, -1, True), (.3, 9, True), (.4, 11, True)])
state = detector.get_state(1)
assert state and state.baseline_acquired and state.baseline_yaw == 0
assert state.baseline_sample_count == 3
assert sum(event.event_type == "baseline_acquired" for event in events) == 1
assert results[-2].turn_level == "none"
assert results[-1].turn_level == "subtle"
def test_baseline_requires_minimum_samples() -> None:
detector = TurnDetector(config(baseline_min_samples=3))
results, _ = feed(detector, [(0, 0, True), (.1, 1, True)])
assert not detector.get_state(1).baseline_acquired # type: ignore[union-attr]
assert results[-1].failure_reason == "baseline_not_acquired"
def test_weak_duration_confirms_once_and_tracks_max_delta() -> None:
detector = TurnDetector(config())
_, events = feed(detector, [(0, 0, True), (.1, 0, True), (.2, 0, True),
(.3, 25, True), (.45, 30, True), (.61, 40, True), (.8, 45, True)])
state = detector.get_state(1)
assert state and state.turn_confirmed and state.max_yaw_delta_toward_signage == 45
assert state.strongest_level == "medium"
assert sum(event.event_type == "turn_confirmed" for event in events) == 1
def test_short_candidate_and_pose_gap_behaviour() -> None:
detector = TurnDetector(config())
results, _ = feed(detector, [(0, 0, True), (.1, 0, True), (.2, 0, True),
(.3, 25, True), (.4, 25, True), (.5, None, False), (.61, 25, True)])
assert results[3].turn_candidate and not results[3].turn_confirmed
assert detector.get_state(1).turn_confirmed # type: ignore[union-attr]
detector2 = TurnDetector(config())
_, events = feed(detector2, [(0, 0, True), (.1, 0, True), (.2, 0, True),
(.3, 25, True), (.4, None, False), (.8, None, False)])
assert any(event.event_type == "turn_lost" for event in events)
def test_face_appeared_and_finalize_classifications() -> None:
detector = TurnDetector(config(min_pose_samples_for_evaluation=3))
_, events = feed(detector, [(0, None, False), (.4, 0, True), (.5, 0, True), (.6, 0, True)])
state = detector.get_state(1)
assert state and state.face_appeared_after_missing
assert any(event.event_type == "face_appeared" for event in events)
final, final_events = detector.finalize_track(1, info(5, .7))
assert final and final.valid_for_turn_analysis and final.exclusion_reason == "evaluable_no_turn"
assert final_events[0].event_type == "turn_track_finalized"
no_pose = TurnDetector(config())
no_pose.update([pose(1, 0, None, True)], info(1, 0))
final, _ = no_pose.finalize_track(1, info(2, .1))
assert final and not final.evaluable and final.exclusion_reason == "no_pose_estimated"
def test_baseline_not_acquired_finalize() -> None:
detector = TurnDetector(config(baseline_min_samples=3))
feed(detector, [(0, 0, True), (.1, 1, True)])
final, _ = detector.finalize_track(1, info(3, .2))
assert final and final.exclusion_reason == "baseline_not_acquired"
+30
View File
@@ -0,0 +1,30 @@
import csv
import json
from pathlib import Path
from src.config_loader import TurnLoggingSettings
from src.models import TurnEvent, TurnFrameResult, TurnTrackState
from src.turn_logger import TurnLogger
def test_three_turn_logs_bom_json_and_summary_deduplication(tmp_path: Path) -> None:
logger = TurnLogger(TurnLoggingSettings(True, True, True, True, 1, str(tmp_path)), "exp", "stamp")
logger.open()
frame = TurnFrameResult(1, 2, "now", .2, True, True, True, 0, 25, 25, 25,
"positive", True, True, "weak", .3)
event = TurnEvent("turn_confirmed", 1, 2, "now", .2, 25, 25, "weak", {"注記": "反応"})
state = TurnTrackState(1, total_frames=5, pose_frames=5, face_detected_frames=5,
baseline_yaw=0, baseline_acquired=True, turn_confirmed=True,
turn_level="weak", evaluable=True, valid_for_turn_analysis=True,
exclusion_reason="turn_detected", finalized=True)
logger.write_frames([frame])
logger.write_events([event])
logger.write_summary(state)
logger.write_summary(state)
logger.close(); logger.close()
paths = list(tmp_path.glob("*.csv"))
assert len(paths) == 3 and all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in paths)
with next(path for path in paths if "events" in path.name).open(encoding="utf-8-sig", newline="") as stream:
assert json.loads(next(csv.DictReader(stream))["metadata_json"])["注記"] == "反応"
with next(path for path in paths if "summary" in path.name).open(encoding="utf-8-sig", newline="") as stream:
assert len(list(csv.DictReader(stream))) == 1
+24
View File
@@ -0,0 +1,24 @@
import pytest
from src.models import TurnEvent, TurnFrameResult, TurnLevel, TurnTrackState
def test_turn_frame_success_failure_and_levels() -> None:
result = TurnFrameResult(1, 2, "now", .2, True, True, True, 0, 25, 25, 25,
"positive", True, False, TurnLevel.WEAK.value, .1)
assert result.yaw_delta_toward_signage == 25
failure = TurnFrameResult(1, 3, "later", .3, False, False, False, None, None, None, None,
"positive", False, False, "not_evaluable", 0, "no_face")
assert failure.turn_level == "not_evaluable"
with pytest.raises(ValueError):
TurnFrameResult(1, 1, "x", 0, True, True, False, None, 0, None, None,
"bad", False, False, "none", 0)
def test_turn_state_rates_and_event() -> None:
state = TurnTrackState(1, total_frames=4, pose_frames=2, face_detected_frames=3)
assert state.face_detection_rate == .75
assert state.pose_estimation_rate == .5
assert state.turn_level == "not_evaluable"
event = TurnEvent("turn_confirmed", 1, 2, "now", .2, 20, 20, "weak", {"注記": "確認"})
assert event.metadata["注記"] == "確認"
+24
View File
@@ -0,0 +1,24 @@
import pytest
from src.config_loader import TurnDetectionSettings
from src.turn_rules import classify_turn_level, yaw_delta_toward_signage
def config() -> TurnDetectionSettings:
return TurnDetectionSettings(True, "positive", "first_valid", .8, 3, 10, 20, 35, 50,
.2, .3, .3, 5, .2, .2, True, .3, True)
def test_signage_delta_directions() -> None:
assert yaw_delta_toward_signage(30, 10, "positive") == (20, 20)
assert yaw_delta_toward_signage(-10, 10, "negative") == (-20, 20)
with pytest.raises(ValueError):
yaw_delta_toward_signage(1, 0, "bad")
@pytest.mark.parametrize(("delta", "level"), [
(9.99, "none"), (10, "subtle"), (19.99, "subtle"), (20, "weak"),
(34.99, "weak"), (35, "medium"), (49.99, "medium"), (50, "strong"),
])
def test_level_boundaries(delta: float, level: str) -> None:
assert classify_turn_level(delta, config()) == level
+18
View File
@@ -0,0 +1,18 @@
from src.config_loader import TurnDetectionSettings
from src.models import FrameInfo, TrackEvent
from src.turn_detector import TurnDetector
from src.turn_track_manager import TurnTrackManager
def test_track_finalization_creates_not_evaluable_summary() -> None:
config = TurnDetectionSettings(True, "positive", "first_valid", .8, 3, 10, 20, 35, 50,
.2, .3, .3, 5, .2, .2, True, .3, True)
detector = TurnDetector(config)
detector.register_tracks([7])
manager = TurnTrackManager(detector)
info = FrameInfo(10, "now", 1.0, 100, 100, 20)
track_event = TrackEvent("track_finalized", 7, 10, "now", 1.0, 0, 0)
states, events = manager.finalize_from_track_events([track_event], info)
assert states[0].turn_level == "not_evaluable"
assert states[0].exclusion_reason == "no_face_detected"
assert events[0].event_type == "turn_track_finalized"
+22
View File
@@ -0,0 +1,22 @@
from src.config_loader import UnifiedLogSettings
from src.models import FrameInfo, PersonStateEvent, TrackEvent, TurnEvent, VoicePromptEvent
from src.unified_event_builder import UnifiedEventBuilder
def cfg(important:bool=False)->UnifiedLogSettings:
return UnifiedLogSettings(True,"x",True,True,True,True,30,True,True,True,True,False,important,"",True)
def test_converts_sources_and_sorts() -> None:
b=UnifiedEventBuilder(cfg(),"exp","session"); f=FrameInfo(2,"t",2.,10,10,1.)
items=[]
items+=b.from_track_events([TrackEvent("trigger_crossed",1,2,"t",2.,1,1)],f)
items+=b.from_turn_events([TurnEvent("turn_confirmed",1,2,"t",2.,1.,20.,"weak")],f)
items+=b.from_voice_events([VoicePromptEvent("prompt_played",1,2,"t",2.,"prompt",True)],f)
items+=b.from_person_state_events([PersonStateEvent("responded",1,2,"t",2.,"observing","responded")],f)
assert {x.source for x in items}=={"tracking","turn","voice","person_state"}
assert len({x.event_id for x in items})==4
assert b.stable_sort(items)==sorted(items,key=lambda x:(x.elapsed_time_sec,x.frame_number,x.source,x.event_id))
def test_important_only_filters() -> None:
b=UnifiedEventBuilder(cfg(True),"e","s");f=FrameInfo(1,"t",1.,1,1,1.)
assert not b.from_track_events([TrackEvent("track_created",1,1,"t",1.,1,1)],f)
+13
View File
@@ -0,0 +1,13 @@
from pathlib import Path
from src.config_loader import UnifiedLogSettings
from src.models import UnifiedEvent,PersonAnalysisRecord,SessionSummary,LogConsistencyIssue
from src.unified_event_logger import UnifiedEventLogger
def test_four_csvs_bom_json_dedup(tmp_path:Path)->None:
c=UnifiedLogSettings(True,str(tmp_path),True,True,True,True,1,True,True,True,True,False,False,"",True)
l=UnifiedEventLogger(c,"exp","sid","stamp");l.open()
e=UnifiedEvent("exp","sid","id","system","x",None,1,"t",1.,metadata={"日本語":"値"})
l.write_events([e,e]);r=PersonAnalysisRecord("exp","sid",1);l.write_analysis([r,r]);s=SessionSummary("exp","sid","t");l.write_session(s);l.write_session(s);l.write_issues([LogConsistencyIssue("unknown","warning",1,None,"m",{"日":"本"})]);l.close();l.close()
assert len(list(tmp_path.glob("*.csv")))==4
assert all(p.read_bytes().startswith(b"\xef\xbb\xbf") for p in tmp_path.glob("*.csv"))
assert "日本語" in (tmp_path/"stamp_exp_unified_events.csv").read_text(encoding="utf-8-sig")
+11
View File
@@ -0,0 +1,11 @@
import pytest
from src.models import UnifiedEvent, PersonAnalysisRecord, SessionSummary, LogConsistencyIssue
def test_models_and_validation() -> None:
event=UnifiedEvent("e","s","id","tracking","created",1,1,"t",0.,metadata={"日本語":True})
assert event.metadata["日本語"]
assert PersonAnalysisRecord("e","s",1).prompt_condition=="unknown"
assert SessionSummary("e","s","t").total_tracks==0
assert LogConsistencyIssue("unknown","warning",None,None,"m").severity=="warning"
with pytest.raises(ValueError): UnifiedEvent("e","s","id","bad","x",None,0,"t",0.)
+6
View File
@@ -0,0 +1,6 @@
import pytest
pytest.importorskip("PySide6")
np=pytest.importorskip("numpy")
from src.gui.video_widget import bgr_to_rgb
def test_bgr_to_rgb()->None:
value=np.array([[[1,2,3]]],dtype=np.uint8);assert bgr_to_rgb(value).tolist()==[[[3,2,1]]]
+26
View File
@@ -0,0 +1,26 @@
import csv
from pathlib import Path
from src.config_loader import VoiceLoggingSettings
from src.models import VoicePromptDecision, VoicePromptEvent, VoicePromptTrackState
from src.voice_logger import VoiceLogger
def test_voice_csvs_bom_json_control_and_dedup(tmp_path: Path) -> None:
config = VoiceLoggingSettings(True, True, True, True, 1, str(tmp_path))
logger = VoiceLogger(config, "exp", "stamp")
logger.open()
logger.write_decisions([VoicePromptDecision(1, 2, "t", 1., "control", True, True, False, True, "pseudo", None)])
logger.write_events([VoicePromptEvent("pseudo_prompt_triggered", 1, 2, "t", 1., "control",
pseudo_prompt=True, metadata={"日本語": "声掛けなし"})])
state = VoicePromptTrackState(1, "control", eligible=True, pseudo_prompt=True, finalized=True)
logger.write_summary(state)
logger.write_summary(state)
logger.close()
logger.close()
for path in tmp_path.glob("*.csv"):
assert path.read_bytes().startswith(b"\xef\xbb\xbf")
event_text = (tmp_path / "stamp_exp_voice_events.csv").read_text(encoding="utf-8-sig")
assert "日本語" in event_text and "声掛けなし" in event_text
with (tmp_path / "stamp_exp_voice_summary.csv").open(encoding="utf-8-sig", newline="") as stream:
assert len(list(csv.DictReader(stream))) == 1
+73
View File
@@ -0,0 +1,73 @@
from unittest.mock import Mock
from src.config_loader import VoicePromptSettings
from src.models import BoundingBox, FrameInfo, TrackEvent, TrackedPerson, TurnFrameResult
from src.voice_prompt_controller import VoicePromptController
def cfg(mode: str = "prompt", **kw: object) -> VoicePromptSettings:
values = dict(enabled=True, mode=mode, trigger_strategy="trigger_line_crossing", require_confirmed_track=True,
require_inside_region=True, skip_if_already_turned=True, one_prompt_per_track=True,
global_cooldown_sec=3., minimum_track_age_sec=0., response_window_sec=3.,
fixed_position_axis="x", fixed_position_value=640., fixed_position_direction="decreasing")
values.update(kw)
return VoicePromptSettings(**values)
def info(frame: int, elapsed: float) -> FrameInfo:
return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 30.)
def person(track_id: int = 1) -> TrackedPerson:
return TrackedPerson(track_id, BoundingBox(1, 1, 20, 40), .9, 0, "person", 10, 20, 10, 40, True, True, 10, 0)
def crossing(track_id: int = 1) -> TrackEvent:
return TrackEvent("trigger_crossed", track_id, 1, "t", 1., 10, 40)
def turn(frame: int, elapsed: float, confirmed: bool = True) -> TurnFrameResult:
return TurnFrameResult(1, frame, f"t{frame}", elapsed, True, True, True, 0., 25., 25., 25., "positive",
True, confirmed, "weak", .4, "")
def test_prompt_success_duplicate_and_response() -> None:
audio = Mock(audio_file="a.wav")
audio.play.return_value = True
controller = VoicePromptController(cfg(), audio)
decisions, events = controller.update([person()], [crossing()], [], info(1, 1.))
assert decisions[0].prompt_played
assert {e.event_type for e in events} >= {"prompt_played", "response_window_started"}
controller.update([person()], [crossing()], [], info(2, 1.1))
assert audio.play.call_count == 1
_, events = controller.update([person()], [], [turn(3, 1.8)], info(3, 1.8))
response = next(e for e in events if e.event_type == "response_detected")
assert response.reaction_time_sec == .8
def test_prompt_failure_control_and_expiration() -> None:
failed = Mock(audio_file="missing.wav")
failed.play.return_value = False
controller = VoicePromptController(cfg(), failed)
_, events = controller.update([person()], [crossing()], [], info(1, 1.))
assert "prompt_failed" in {event.event_type for event in events}
control_audio = Mock(audio_file="a.wav")
control = VoicePromptController(cfg("control", response_window_sec=1.), control_audio)
decisions, events = control.update([person()], [crossing()], [], info(1, 1.))
assert decisions[0].pseudo_prompt and not decisions[0].prompt_played
control_audio.play.assert_not_called()
_, events = control.update([person()], [], [], info(2, 2.1))
assert "response_window_expired" in {event.event_type for event in events}
def test_cooldown_and_finalize_all() -> None:
audio = Mock(audio_file="a.wav")
audio.play.return_value = True
controller = VoicePromptController(cfg(), audio)
controller.update([person(1)], [crossing(1)], [], info(1, 1.))
decisions, _ = controller.update([person(2)], [crossing(2)], [], info(2, 2.))
assert decisions[0].reason == "global_cooldown"
states, events = controller.finalize_all(info(3, 5.))
assert len(states) == 2 and all(state.finalized for state in states)
assert all(event.event_type == "voice_track_finalized" for event in events)
+37
View File
@@ -0,0 +1,37 @@
from src.config_loader import VoicePromptSettings
from src.models import BoundingBox, TrackEvent, TrackedPerson, VoicePromptTrackState
from src.voice_rules import eligibility_reason, trigger_matches
def cfg(strategy: str = "trigger_line_crossing", **kw: object) -> VoicePromptSettings:
values = dict(enabled=True, mode="prompt", trigger_strategy=strategy, require_confirmed_track=True,
require_inside_region=True, skip_if_already_turned=True, one_prompt_per_track=True,
global_cooldown_sec=3., minimum_track_age_sec=0., response_window_sec=3.,
fixed_position_axis="x", fixed_position_value=50., fixed_position_direction="increasing")
values.update(kw)
return VoicePromptSettings(**values)
def person(x: float = 60, confirmed: bool = True, inside: bool = True) -> TrackedPerson:
return TrackedPerson(1, BoundingBox(40, 10, 80, 100), .9, 0, "person", 60, 55, x, 100,
inside, confirmed, 10, 0)
def event(kind: str) -> TrackEvent:
return TrackEvent(kind, 1, 1, "t", 1., 60, 100)
def test_event_and_position_triggers() -> None:
assert trigger_matches(cfg(), person(), [event("trigger_crossed")])
assert trigger_matches(cfg("region_entry"), person(), [event("region_entered")])
assert trigger_matches(cfg("fixed_x_position"), person(), [])
def test_eligibility_guards() -> None:
state = VoicePromptTrackState(1, "prompt")
assert eligibility_reason(cfg(), person(confirmed=False), state, None, 0) == "unconfirmed_track"
assert eligibility_reason(cfg(), person(inside=False), state, None, 0) == "outside_region"
assert eligibility_reason(cfg(), person(), state, None, 1) == "global_cooldown"
state.prompt_attempted = True
assert eligibility_reason(cfg(), person(), state, None, 0) == "already_prompted"