From a48b685c1d096afdbbe44aab2449cabd3c3fff98 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 20 Oct 2026 23:08:12 +0900 Subject: [PATCH 01/65] =?UTF-8?q?eyecam:=20=E9=96=8B=E3=81=84=E3=81=9F?= =?UTF-8?q?=E7=9B=AE=E3=82=88=E3=82=8A=E5=A4=A7=E3=81=8D=E3=81=8F=E3=81=AA?= =?UTF-8?q?=E3=81=A3=E3=81=9F=E7=9E=B3=EF=BC=88=E6=9A=97=E3=81=8F=E3=81=AA?= =?UTF-8?q?=E3=81=A3=E3=81=A6=E9=96=8B=E3=81=84=E3=81=9F=E7=9E=B3=E5=AD=94?= =?UTF-8?q?=EF=BC=89=E3=82=92=E3=80=81=E5=A4=A7=E3=81=8D=E3=81=95=E3=81=A0?= =?UTF-8?q?=E3=81=91=E3=81=A7=E6=8D=A8=E3=81=A6=E7=B6=9A=E3=81=91=E3=81=AA?= =?UTF-8?q?=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 開いた目の確かめ(前の瞳を見失ったあとは、開いた目の瞳の 0.45〜1.3 倍か)は、瞳の大きさを 受け入れた瞳の 20 秒の中央値で覚えている。まばたきで前の瞳を忘れたとき、瞳がもう 1.3 倍を 超えていると、暗さも見え方も開いた目のままなのに大きさだけで捨てられ、受け入れないので中央値も 動かず、瞳が縮むまで見つからなかった(録画 26 本で 1 回、目が開いたまま 7.4 秒)。 - 大きさだけで捨てた瞳(1.3〜2.5 倍、暗さと見え方は開いた目と同じ、iris の box は皮膚ほど明るくない、 前の瞳なし)が 0.3 秒続いたら受け入れ、開いた目の瞳の大きさを覚え直す - テスト: 半径 20 で覚えたあと、まばたきして 30 で戻った瞳が 0.3 秒以内に見つかり、そのまま続く - 録画 26 本と校正 10 本: 34/36 がバイト単位で同じ。違う 2 本(大きさで捨て続けていた録画)で、 開いた目の瞳の検出 88.81% → 89.01%、誤った半目 5.53% → 5.37%、閉じ・細め・見開き・校正は同じ Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/src/live.rs | 56 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index 3ef2369..45a1d06 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -44,6 +44,14 @@ const PUPIL_SETTLE_S: f64 = 0.3; /// was (its prior): a wrong blob caught once (during a blink, say) would otherwise keep the real pupil out of reach /// for seconds. pub const PRIOR_RESET_S: f64 = 10.0 / FPS; +/// A pupil the open-eye check drops only for its size (larger than PUPIL_MAX_GROW x the open pupil learnt, but no +/// more than PUPIL_GROW_MAX x, as dark and as whole as the open one, the iris box not as bright as lid skin) for this +/// long is a dilated pupil (the world got darker): it is taken, and the open pupil's size starts over from it. The +/// open pupil's size is a 20 s median of the pupils taken, so without this a pupil dilated past PUPIL_MAX_GROW x +/// before the median caught up was never taken again once a blink had dropped the search prior. +const PUPIL_GROW_S: f64 = 0.3; +const PUPIL_MAX_GROW: f64 = 1.3; +const PUPIL_GROW_MAX: f64 = 2.5; /// Closed needs no pupil for this long (3 frames at 90 fps; at least 2 frames). const CLOSED_NO_PUPIL_S: f64 = 3.0 / FPS; /// A pupil lost while the last one seen had its small-x edge within this many px of the search window's left edge ran @@ -516,6 +524,8 @@ pub struct EyeEngine { bx: Win, pd: Win, nopupil: u32, + /// Frames in a row the open-eye check dropped the pupil only for being large (PUPIL_GROW_S). + grown: u32, box_open: LongMedian, /// The open eye's pupil semi-axis (px) and dark level, for telling a real pupil from a crease on a closed lid. a_open: LongMedian, @@ -741,6 +751,7 @@ impl Default for EyeEngine { bx: Win::new(), pd: Win::new(), nopupil: 0, + grown: 0, box_open: LongMedian::new(60.0), a_open: LongMedian::new(20.0), inner_open: LongMedian::new(20.0), @@ -951,10 +962,24 @@ impl EyeEngine { let open_box = self.levels.unlevel(self.box_open.value); let box_bright = self.box_open.len() >= 30 && self.levels.unlevel(f.box_mean) > 1.3 * open_box; let unanchored = no_prior || f.diag.retried; + let grown = p.is_some_and(|pp| { + unanchored + && !box_bright + && self.a_open.len() >= 30 + && pp.a / self.a_open.value > PUPIL_MAX_GROW + && pp.a / self.a_open.value <= PUPIL_GROW_MAX + && pp.inner <= self.inner_open.value + self.levels.step(15.0) + && pp.vis >= 0.5 + }); + self.grown = if grown { self.grown + 1 } else { 0 }; + if self.grown >= frames_in(PUPIL_GROW_S, fps, 2) { + self.a_open = LongMedian::new(20.0); + self.grown = 0; + } if let Some(pp) = p && (box_bright || unanchored) && self.a_open.len() >= 30 - && (!(0.45..=1.3).contains(&(pp.a / self.a_open.value)) + && (!(0.45..=PUPIL_MAX_GROW).contains(&(pp.a / self.a_open.value)) || pp.inner > self.inner_open.value + self.levels.step(15.0) || pp.vis < 0.5) { @@ -3498,6 +3523,35 @@ mod tests { assert!(e.r_est.len() > 0 || e.cm_n > 0, "what belongs to the wear stays"); } + #[test] + fn a_pupil_that_dilated_past_the_size_check_is_taken_again() { + // The open pupil learnt at radius 20; after a blink (the search prior dropped) it comes back 1.5 x larger (the + // world got darker): the open-eye check drops it for its size only, so it is taken after PUPIL_GROW_S and + // stays (before, the 20 s median of the pupils taken never caught up: no pupil until it shrank again). + let mut e = EyeEngine::default(); + let p = Params::default(); + let (small, large, blank) = + (disc_frame(Some((250.0, 200.0, 20.0))), disc_frame(Some((250.0, 200.0, 30.0))), disc_frame(None)); + let mut t = 0.0; + let mut run = |e: &mut EyeEngine, img: &[u8], n: usize| -> Vec { + (0..n) + .map(|_| { + t += 1.0 / FPS; + e.process(img, f64::NAN, t, &p).f.ok_pupil + }) + .collect() + }; + assert!(run(&mut e, &small, 450).iter().all(|&ok| ok)); + assert!(e.a_open.len() >= 30); + run(&mut e, &blank, 20); + assert!(e.prev.is_none(), "the blink dropped the prior"); + let back = run(&mut e, &large, 180); + let first = back.iter().position(|&ok| ok).expect("taken again"); + assert!(first <= frames_in(PUPIL_GROW_S, FPS, 2) as usize, "{first}"); + assert!(back[first..].iter().all(|&ok| ok), "and kept"); + assert!(e.a_open.value > 25.0, "the open pupil's size starts over: {}", e.a_open.value); + } + #[test] fn at_15_fps_the_prior_goes_after_the_same_time() { let mut e = EyeEngine::default(); From 77c3acb4eaabbe19f1b10ced819ea953dd3679f5 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 20 Oct 2026 23:08:29 +0900 Subject: [PATCH 02/65] =?UTF-8?q?eyecam:=20=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E6=A0=A1=E6=AD=A3=E3=81=AE=E7=B4=B0=E3=82=81=E3=82=92?= =?UTF-8?q?=E3=80=81=E3=81=9D=E3=81=AE=E3=81=8B=E3=81=B6=E3=82=8A=E3=81=AE?= =?UTF-8?q?=E9=96=8B=E3=81=84=E3=81=9F=E7=9B=AE=E3=81=8B=E3=82=89=E3=81=AE?= =?UTF-8?q?=E6=B7=B1=E3=81=95=E3=81=A7=E3=82=82=E8=A6=9A=E3=81=88=E3=80=81?= =?UTF-8?q?=E3=83=A9=E3=82=A4=E3=83=96=E3=81=A7=E3=81=AF=E3=81=9D=E3=81=AE?= =?UTF-8?q?=E6=B7=B1=E3=81=95=E3=81=A7=E7=B4=B0=E3=82=81=E3=82=92=E5=87=BA?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ユーザー校正の細め f_sq は、そのかぶりの閉じと開きの間の割合だが、ライブは履歴の中央値の 間(閉じと開きの差)にその割合を掛けていた。かぶりごとの間は履歴の中央値の 0.58〜1.50 倍 (開発者の 40 回)なので、校正した細めが 0.65〜1.52 の細めとして出ていた(ユーザー校正 5 回)。 - UserParams.sq_depth: 細めの開きが開いた目より何 R 狭いか(そのかぶりの値)。校正で書き、 ライブはあればそれを今の開きから引いた所を細め 1 にする。ないファイル(これまでの校正)は前と同じ - テスト: 校正で深さが入り、ファイルを通っても同じ。深さのない校正はファイルに書かない - 録画 26 本と校正 10 本(深さのない校正ファイル): 36/36 がバイト単位で同じ - 深さを入れた校正ファイルで流すと(各ユーザー校正の直前のかぶりの間から作った): 合図の細めの EyeSquint の平均 0.760 → 0.706(≥ 0.5 は 0.821 → 0.812、≥ 0.9 は 0.534 → 0.351)、合図の 開いた目で細め ≥ 0.5 は 2.4% → 0.8%。浅く出ていた校正(0.65 / 0.84 倍)は 0.455 → 0.701、0.584 → 0.676 Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/src/live.rs | 47 ++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index 45a1d06..7dc939d 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -233,11 +233,24 @@ pub struct UserParams { pub f_sq: f64, pub pd_min: f64, pub pd_max: f64, + /// The squint aperture's depth below the open one (iris radii), as calibrated: live puts the squint this far below + /// the open level in use. A wear's own open-to-closed gap (that f_sq is a fraction of) is 0.6..1.5 of the + /// history's median gap live uses, so f_sq alone put the calibrated squint at 0.65..1.5 of full squint + /// (the developer's 5 user calibrations, 2026-10-08..14). None (calibrations before it): f_sq of the history's gap. + pub sq_depth: Option, } impl Default for UserParams { fn default() -> Self { - Self { pitch_c: [0.0; 3], pitch_lo: -90.0, pitch_hi: 90.0, f_sq: 0.31, pd_min: 0.6, pd_max: 1.05 } + Self { + pitch_c: [0.0; 3], + pitch_lo: -90.0, + pitch_hi: 90.0, + f_sq: 0.31, + pd_min: 0.6, + pd_max: 1.05, + sq_depth: None, + } } } @@ -1133,7 +1146,10 @@ impl EyeEngine { let p0 = if p_typ.is_finite() { p_typ } else { DEFAULT_PITCH_N }; let apc = if pitch.is_finite() { ap_px + user.pitch_drop(pitch, p0) * r } else { ap_px }; let ap_cl_px = ap_n_px - gap_px; - let ap_sq = ap_cl_px + user.f_sq * (ap_n_px - ap_cl_px); + let ap_sq = match user.sq_depth { + Some(d) => ap_n_px - d * r, + None => ap_cl_px + user.f_sq * (ap_n_px - ap_cl_px), + }; // Without a user calibration (squint depth, pitch curve) the aperture alone is not trusted: no squint, and the // lid only closes when the eye is closed. Nothing either before the open level is known. let user_cal = params.user.is_some() && ready; @@ -1730,8 +1746,9 @@ fn fit_user_eye( let s = &s[..]; let (ap_sq, nsq) = med_of(s, |x| (x.label == Label::Squint).then_some(x.ap)); let f_sq = (ap_sq - w.ap_cl) / (w.ap_n - w.ap_cl); + let sq_depth = Some(w.ap_n - ap_sq).filter(|d| d.is_finite()); // Without the curve: the standard look-down correction (as with no user calibration), this squint. - let standard = UserParams { f_sq, pd_min: keep.pd_min, pd_max: keep.pd_max, ..UserParams::default() }; + let standard = UserParams { f_sq, sq_depth, pd_min: keep.pd_min, pd_max: keep.pd_max, ..UserParams::default() }; let pts: Vec<(f64, f64)> = s.iter().filter(|x| neutral(x.label) && x.ok && x.pitch.is_finite() && x.ap.is_finite()).map(|x| (x.pitch, x.ap)).collect(); if pts.len() < need.points { @@ -1783,7 +1800,8 @@ fn fit_user_eye( look_down_only = k > 0; } } - let params = UserParams { pitch_c, pitch_lo: lo, pitch_hi: hi, f_sq, pd_min: keep.pd_min, pd_max: keep.pd_max }; + let params = + UserParams { pitch_c, pitch_lo: lo, pitch_hi: hi, f_sq, pd_min: keep.pd_min, pd_max: keep.pd_max, sq_depth }; Ok(UserEye { params, delta: (ap_down - w.ap_n, ap_sq - w.ap_n), issue, look_down_only }) } @@ -2021,7 +2039,7 @@ fn wear_json(w: &WearParams) -> String { fn user_json(u: &UserParams) -> String { format!( - "{{\"pitch_c\": [{}, {}, {}], \"pitch_lo\": {}, \"pitch_hi\": {}, \"f_sq\": {}, \"pd_min\": {}, \"pd_max\": {}}}", + "{{\"pitch_c\": [{}, {}, {}], \"pitch_lo\": {}, \"pitch_hi\": {}, \"f_sq\": {}, \"pd_min\": {}, \"pd_max\": {}{}}}", json::num(u.pitch_c[0]), json::num(u.pitch_c[1]), json::num(u.pitch_c[2]), @@ -2029,7 +2047,8 @@ fn user_json(u: &UserParams) -> String { json::num(u.pitch_hi), json::num(u.f_sq), json::num(u.pd_min), - json::num(u.pd_max) + json::num(u.pd_max), + u.sq_depth.map_or(String::new(), |d| format!(", \"sq_depth\": {}", json::num(d))) ) } @@ -2062,6 +2081,7 @@ fn parse_user(j: &Json) -> Result { f_sq: get_num(j, "f_sq")?, pd_min: get_num(j, "pd_min")?, pd_max: get_num(j, "pd_max")?, + sq_depth: j.get("sq_depth").and_then(Json::num).filter(|d| d.is_finite()), }) } @@ -3023,6 +3043,11 @@ mod tests { assert_eq!(ulines.len(), 3); assert_eq!(samples_csv(&both).lines().count(), 1 + 2 * 580); assert!((user[0].f_sq - (0.831 - 0.561) / (1.441 - 0.561)).abs() < 0.01, "{:?}", user[0]); + // The squint's depth below the open aperture, in this wear's iris radii, kept with it (and through the file). + assert!((user[0].sq_depth.unwrap() - (1.441 - 0.831)).abs() < 0.01, "{:?}", user[0]); + assert_eq!(parse_user(&crate::json::parse(&user_json(&user[0])).unwrap()).unwrap(), user[0]); + let old = UserParams { sq_depth: None, ..user[0] }; + assert!(!user_json(&old).contains("sq_depth"), "files without it stay as they were"); // A look_down that did not lower the lid is refused. let mut bad = u.clone(); bad.retain(|s| s.label != Label::LookDown); @@ -3431,7 +3456,15 @@ mod tests { let good = user(1.1, 256.0, 0.83); let lashes = user(1.43, 256.0, 0.83); // The previous user calibration: a measured curve on both eyes (another one on the right), its own squint. - let measured = |slope: f64| UserParams { pitch_c: [1.5, slope, 0.0], pitch_lo: -40.0, pitch_hi: 20.0, f_sq: 0.5, pd_min: 0.5, pd_max: 1.2 }; + let measured = |slope: f64| UserParams { + pitch_c: [1.5, slope, 0.0], + pitch_lo: -40.0, + pitch_hi: 20.0, + f_sq: 0.5, + pd_min: 0.5, + pd_max: 1.2, + sq_depth: None, + }; let old = Some([measured(0.01), measured(0.02)]); assert!(old.unwrap().iter().all(UserParams::curve_measured)); assert!(!UserParams { f_sq: 0.5, pd_min: 0.5, ..UserParams::default() }.curve_measured()); From 9022e50e0f4adceab6399fe6927f2d91c5828e51 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 20 Oct 2026 23:08:12 +0900 Subject: [PATCH 03/65] =?UTF-8?q?replay:=20=E7=9B=AE=E3=81=AE=E3=81=BE?= =?UTF-8?q?=E3=81=B6=E3=81=9F=E3=81=AE=E3=80=8C=E7=96=91=E3=81=84=E3=80=8D?= =?UTF-8?q?=EF=BC=88LidDoubt=EF=BC=89=E3=82=92=E8=AA=BF=E3=81=B9=E3=82=8B?= =?UTF-8?q?=E8=A8=BA=E6=96=AD=E3=82=92=E8=B6=B3=E3=81=99=EF=BC=88FEO=5FDOU?= =?UTF-8?q?BT=5FDIAG=E3=80=81=E6=97=A2=E5=AE=9A=E3=81=A7=E3=81=AF=E4=BD=95?= =?UTF-8?q?=E3=82=82=E5=A4=89=E3=82=8F=E3=82=89=E3=81=AA=E3=81=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FEO_DOUBT_DIAG を付けて --replay すると、目ごとにカメラのまぶたへ切り替わった回数と時刻、それまでに疑いを足した閉じの数、 まばたきが送られずに終わった数、目を開け直したあとカメラの「閉じ」で 0 に留まった長さを出す - FEO_CAM_FPS / FEO_CAM_DELAY_MS で、記録したカメラを遅いカメラ(例: 15 fps、処理の遅れ 167 ms)に見立てて流し直せる - LidDoubt に診断用の値(直前の目のサーバーのまぶた、閉じかけか、一致したか)を持たせる。出力には使わない Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main.rs | 4 + src/replay.rs | 285 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) diff --git a/src/main.rs b/src/main.rs index c86b180..2b81790 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1237,6 +1237,7 @@ impl Smoother { } else { !closing && valve >= camera.lid.min(LID_RELAXED) - CAMERA_AGREE_GAP }; + state.diag = (valve, closing, agree); if closing && !camera.closed { state.doubted += f64::from(dt); state.agree_since = None; @@ -1816,6 +1817,9 @@ struct LidDoubt { agree_since: Option, // Whether the eyelid is the camera's. camera: bool, + // For the replay's diagnostics (FEO_DOUBT_DIAG): the last sample's eye-server eyelid, whether it was closing (below + // the doubt line), and whether the two agreed. + diag: (f32, bool, bool), } /// Where a sent eyelid came from, beyond the usual mix (the eye server's below relaxed open, the camera's from there diff --git a/src/replay.rs b/src/replay.rs index dd59924..da50a76 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -198,6 +198,62 @@ impl RecordedGaps { } } +/// Diagnostics only (FEO_CAM_FPS, FEO_CAM_DELAY_MS): the recorded camera stream as a slower camera would have given it, +/// each eye's newest frame on a 1/fps grid taken at least `delay` before the sample, with its age and freshness to +/// match. +struct Resampler { + interval: f64, + delay: f64, + // Per eye: recorded frames not yet due (taken, values, fresh), and the one being served. + pending: [std::collections::VecDeque<(f64, Eye, bool)>; 2], + current: [Option<(f64, Eye, bool)>; 2], + last_taken: [Option; 2], +} + +impl Resampler { + fn from_env() -> Option { + let read = |name: &str| std::env::var(name).ok().and_then(|value| value.parse::().ok()); + let fps = read("FEO_CAM_FPS"); + let delay = read("FEO_CAM_DELAY_MS").unwrap_or(0.0) / 1000.0; + (fps.is_some() || delay > 0.0).then(|| Self { + interval: fps.map_or(0.0, |fps| 1.0 / fps), + delay, + pending: Default::default(), + current: [None; 2], + last_taken: [None; 2], + }) + } + + fn apply(&mut self, live: &mut Live, ages: &mut [f64; 2], sample_time: f64) { + for eye in 0..2 { + let taken = sample_time - ages[eye] / 1000.0; + if taken.is_finite() && self.last_taken[eye].is_none_or(|last| (taken - last).abs() * 1e9 >= SAME_FRAME_NS as f64) { + self.last_taken[eye] = Some(taken); + self.pending[eye].push_back((taken, live.eyes[eye], live.fresh[eye])); + } + while let Some(&(due, values, fresh)) = self.pending[eye].front() { + if due > sample_time - self.delay { + break; + } + self.pending[eye].pop_front(); + if self.current[eye].is_none_or(|(served, ..)| due - served >= self.interval - 0.002) { + self.current[eye] = Some((due, values, fresh)); + } + } + match self.current[eye] { + Some((served, values, fresh)) => { + // The delay stands for processing lag (the values are late), not an older capture time + let age = sample_time - served - self.delay; + live.eyes[eye] = values; + live.fresh[eye] = fresh && live.live && age <= (2.0 * self.interval).max(0.1); + ages[eye] = age * 1000.0; + } + None => live.fresh[eye] = false, + } + } + } +} + /// The sample `values` came from. fn from_values(values: &[f64]) -> EyeData { let mut floats = values[1..].iter().map(|value| *value as f32); @@ -292,6 +348,7 @@ fn parse(text: &str) -> Result<(Vec, bool), String> { .collect::, _>>()?; let camera_positions = camera_columns().iter().map(position).collect::>>(); let mut gaps = RecordedGaps::default(); + let mut resampler = Resampler::from_env(); let rows = lines .map(|(index, line)| { let not_numbers = || format!("line {}: not a row of numbers", index + 1); @@ -312,6 +369,9 @@ fn parse(text: &str) -> Result<(Vec, bool), String> { live.fresh.swap(0, 1); ages.swap(0, 1); } + if let Some(resampler) = &mut resampler { + resampler.apply(&mut live, &mut ages, data.sample_time); + } gaps.note(&mut live, data.sample_time, ages); live }) @@ -345,6 +405,228 @@ fn replay(samples: &[EyeData], cameras: &[Option], settings: &Settings, mu .collect() } +/// Diagnostics only (FEO_DOUBT_DIAG): how each eye's LidDoubt went over a recording, as report lines. Per eye: when +/// its eyelid switched to the camera's (state.camera), after how many eye-server closings since the doubt last +/// cleared and how long it stayed; the CameraOpen stretches, and how many began while the eye server read the eye +/// closing; how often the two did not agree while both read it open; and after each eye-server closing, how long the +/// camera's "closed" kept the eyelid at 0 (CameraClosed) and the sent eyelid stayed shut, with the camera and without. +fn doubt_diag(samples: &[EyeData], cameras: &[Option], settings: &Settings, calibration: &LidCalibration) -> String { + let mut smoother = Smoother::new(settings); + let mut cal = calibration.clone(); + let mut since = None; + let mut last: Option = None; + let mut sent = Vec::with_capacity(samples.len()); + let mut states = Vec::with_capacity(samples.len()); + for (data, camera) in samples.iter().zip(cameras) { + let time = data.sample_time; + if last.is_some_and(|last| time - last >= TIMEOUT.as_secs_f64()) { + since = None; + } + last = Some(time); + let settled = time - *since.get_or_insert(time) >= CAL_SETTLE.as_secs_f64(); + sent.push(step(settings, &mut smoother, &mut cal, data, settled, camera.as_ref())); + states.push(smoother.lid_doubt); + } + let off = replay(samples, cameras, &Settings { camera_lids: false, ..settings.clone() }, calibration.clone()); + let times: Vec = samples.iter().map(|data| data.sample_time).collect(); + let start = times.first().copied().unwrap_or(0.0); + let hours = (times.last().copied().unwrap_or(0.0) - start) / 3600.0; + let mut text = format!("\nLidDoubt diagnostics ({hours:.3} h)\n"); + let stats = |values: &mut Vec| { + let count = values.len(); + let positive = values.iter().filter(|value| **value > 0.0).count(); + if count == 0 { + return "none".to_owned(); + } + let total: f64 = values.iter().sum(); + format!( + "n {count}, >0 {positive}, median {:.0} ms, p90 {:.0} ms, max {:.0} ms, total {:.1} s", + percentile(values, 50.0) * 1000.0, + percentile(values, 90.0) * 1000.0, + percentile(values, 100.0) * 1000.0, + total + ) + }; + let n = samples.len(); + for eye in 0..2 { + let mut flips: Vec = Vec::new(); + let mut camera_seconds = 0.0; + let mut closings_since_clear = 0; + // Doubt brought into the current eye-server closing from earlier ones, and the closings that added to it + let mut carried = 0.0; + let mut adding = 0; + let mut run_added = false; + // Eye-server closings as (first sample, first sample after it) + let mut runs: Vec<(usize, usize)> = Vec::new(); + let mut increments = Vec::new(); + let mut run_from = 0; + let mut carried_flips = 0; + let mut adding_at_flip = Vec::new(); + let mut clear_time = start; + let mut flip_start: Option = None; + let mut on_durations = Vec::new(); + let mut open_runs = 0; + let mut open_runs_closing = 0; + let mut closings = 0; + let mut both_open = 0; + let mut not_agree = 0; + let mut max_doubt = 0.0_f64; + let mut tails_closed = Vec::new(); + let mut tails_shut = Vec::new(); + let mut tails_shut_off = Vec::new(); + for i in 0..n { + let state = states[i][eye]; + let previous = (i > 0).then(|| states[i - 1][eye]); + let (_, closing, agree) = state.diag; + let has_camera = sent[i].camera.squint[eye].is_some(); + let dt = if i > 0 { (times[i] - times[i - 1]).clamp(0.0, MAX_GAP) } else { 0.0 }; + max_doubt = max_doubt.max(state.doubted); + if previous.is_some_and(|previous| previous.doubted > 0.0) && state.doubted == 0.0 { + closings_since_clear = 0; + adding = 0; + clear_time = times[i]; + } + let was_closing = previous.is_some_and(|previous| previous.diag.1); + if closing && !was_closing { + closings += 1; + closings_since_clear += 1; + carried = previous.map_or(0.0, |previous| previous.doubted); + run_added = false; + run_from = i; + } + if previous.is_some_and(|previous| state.doubted > previous.doubted) && !run_added { + adding += 1; + run_added = true; + } + if state.camera { + camera_seconds += dt; + } + if state.camera && !previous.is_some_and(|previous| previous.camera) { + flip_start = Some(times[i]); + // Mostly brought in from earlier closings: the doubt added up over blinks, not one long closing + if closing && carried >= 0.5 * state.doubted { + carried_flips += 1; + adding_at_flip.push(adding as f64); + } + flips.push(format!( + "{:.1} s (doubt {:.0} ms, {:.0} ms of it from earlier closings; {} closings added to it, {} closings since it last cleared {:.1} s ago)", + times[i] - start, + state.doubted * 1000.0, + carried * 1000.0, + adding, + closings_since_clear, + times[i] - clear_time + )); + } + if !state.camera { + if let Some(from) = flip_start.take() { + on_durations.push(times[i] - from); + } + } + let camera_open = sent[i].lid_from[eye] == LidFrom::CameraOpen; + if camera_open && (i == 0 || sent[i - 1].lid_from[eye] != LidFrom::CameraOpen) { + open_runs += 1; + if closing { + open_runs_closing += 1; + } + } + if has_camera && !closing && sent[i].lid_from[eye] != LidFrom::CameraClosed { + both_open += 1; + if !agree { + not_agree += 1; + } + } + // The eye server reopened: how long the camera's "closed" kept it at 0, and the sent eyelid stayed shut + if was_closing && !closing { + runs.push((run_from, i)); + let added = previous.map_or(0.0, |previous| previous.doubted) - carried; + if added > 0.0 { + increments.push(added); + } + let run = |shut: &dyn Fn(usize) -> bool| { + let mut j = i; + while j < n && shut(j) && (j == i || times[j] - times[j - 1] < MAX_GAP) { + j += 1; + } + if j > i { times[j.min(n - 1)] - times[i] } else { 0.0 } + }; + tails_closed.push(run(&|j| sent[j].lid_from[eye] == LidFrom::CameraClosed)); + tails_shut.push(run(&|j| sent[j].lids[eye] <= LID_SHUT)); + tails_shut_off.push(run(&|j| off[j].lids[eye] <= LID_SHUT)); + } + } + if let Some(from) = flip_start { + on_durations.push(times[n - 1] - from); + } + // Blinks (closings up to half a second): whether the sent eyelid got shut (by BLINK_TAIL after), with the camera + // and without; how late it got shut; and whether the eye was on the camera's eyelid then + let mut blinks = 0; + let mut missed = [0; 2]; + let mut lost = 0; + let mut lost_on_camera = 0; + let mut on_camera_blinks = 0; + let mut late = [Vec::new(), Vec::new()]; + for &(from, to) in &runs { + if times[to] - times[from] > 0.5 { + continue; + } + blinks += 1; + let shut_at = |lids: &dyn Fn(usize) -> f32| { + (from..n).take_while(|j| times[*j] <= times[to] + BLINK_TAIL).find(|j| lids(*j) <= LID_SHUT) + }; + let shut = [shut_at(&|j| sent[j].lids[eye]), shut_at(&|j| off[j].lids[eye])]; + let on_camera = (from..to).any(|j| states[j][eye].camera); + on_camera_blinks += usize::from(on_camera); + for k in 0..2 { + match shut[k] { + Some(j) => late[k].push(times[j] - times[from]), + None => missed[k] += 1, + } + } + if shut[0].is_none() && shut[1].is_some() { + lost += 1; + lost_on_camera += usize::from(on_camera); + } + } + let [late_on, late_off] = &mut late; + let side = ["L", "R"][eye]; + text.push_str(&format!( + " {side}: switches to the camera's eyelid {} ({:.1}/h); on it {:.1} s ({:.1}%); max doubt {:.0} ms\n stretches on it: {}\n", + flips.len(), + flips.len() as f64 / hours.max(1e-9), + camera_seconds, + camera_seconds * 100.0 / (hours * 3600.0).max(1e-9), + max_doubt * 1000.0, + stats(&mut on_durations), + )); + text.push_str(&format!( + " of them mostly carried over from earlier closings: {carried_flips} (closings adding to it: median {:.0})\n", + median(&mut adding_at_flip) + )); + for flip in &flips { + text.push_str(&format!(" at {flip}\n")); + } + text.push_str(&format!( + " CameraOpen stretches {open_runs}, {open_runs_closing} begun while the eye server read it closing; eye-server closings {closings}\n" + )); + text.push_str(&format!( + " both reading it open, not agreeing: {:.1}% of {both_open} samples\n", + not_agree as f64 * 100.0 / both_open.max(1) as f64 + )); + text.push_str(&format!( + " blinks {blinks} ({on_camera_blinks} on the camera's eyelid): not sent shut {} with camera, {} without; lost by the camera {lost} ({lost_on_camera} of them on its eyelid)\n", + missed[0], missed[1] + )); + text.push_str(&format!(" doubt added per closing that added any: {}\n", stats(&mut increments))); + text.push_str(&format!(" blink shut after its start (with camera): {}\n", stats(late_on))); + text.push_str(&format!(" blink shut after its start (camera off): {}\n", stats(late_off))); + text.push_str(&format!(" after reopening, camera closed held at 0: {}\n", stats(&mut tails_closed))); + text.push_str(&format!(" after reopening, sent shut (with camera): {}\n", stats(&mut tails_shut))); + text.push_str(&format!(" after reopening, sent shut (camera off): {}\n", stats(&mut tails_shut_off))); + } + text +} + /// The settings with this version's new stages turned off, to compare against. fn without_new_stages(settings: &Settings) -> Settings { Settings { @@ -913,6 +1195,9 @@ pub fn run(input: &Path, output: Option<&Path>, settings: &Settings, calibration let (before, after) = (metrics(&samples, &before), metrics(&samples, &after)); print!("{}", report(input, &samples, all.len() - samples.len(), settings, &before, &after)); print!("{}", camera_text.unwrap_or_default()); + if camera && std::env::var_os("FEO_DOUBT_DIAG").is_some() { + print!("{}", doubt_diag(&samples, &cameras, settings, calibration)); + } let header: Vec<&str> = text.lines().next().unwrap_or("").split(',').map(str::trim).collect(); if camera_eyes_swapped(&header) { println!( From 4a276bcb4aa6be7b6b44a0e715e79cfa3bba1068 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 20 Oct 2026 23:08:13 +0900 Subject: [PATCH 04/65] =?UTF-8?q?=E3=81=BE=E3=81=B6=E3=81=9F:=20=E7=9B=AE?= =?UTF-8?q?=E3=81=AE=E3=82=B5=E3=83=BC=E3=83=90=E3=83=BC=E3=81=8C=201=20?= =?UTF-8?q?=E7=A7=92=E9=96=8B=E3=81=84=E3=81=A6=E3=81=84=E3=82=8B=E3=81=A8?= =?UTF-8?q?=E8=AA=AD=E3=82=93=E3=81=A0=E3=82=89=E3=80=81=E3=82=AB=E3=83=A1?= =?UTF-8?q?=E3=83=A9=E3=81=AE=E3=81=BE=E3=81=B6=E3=81=9F=E3=81=B8=E3=81=AE?= =?UTF-8?q?=E3=80=8C=E7=96=91=E3=81=84=E3=80=8D=E3=82=92=E3=82=84=E3=82=8A?= =?UTF-8?q?=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目のサーバーが開いた目をカメラより CAMERA_AGREE_GAP 以上低く読む(0.4.x で開いた目が 0.6-0.95、フィットを通して VRCFT 0.59、 カメラは 0.75)と、二つが一致することがなく、疑いが消えない。まばたきのたびにカメラの遅れ(中央値 44-67 ms)が足され、 間が何分空いても 7 回ほどでその目のまぶたがカメラのものになり、カメラが見逃したまばたきが開いたまま送られる。 CAMERA_DOUBT_CLEAR_OPEN(1 秒)開いていれば、一致していなくても疑いを 0 に戻し、まぶたを目のサーバーのものに戻す。 記録(2026-10-08 と 10-16、計 0.67 時間)の流し直し: カメラのまぶたに切り替わった回数は L 63 / R 79 のまま、カメラのまぶたで いた時間が R 130 → 111 s(blink-sync 0.35 では 218 → 194 s)。送るまぶたが変わったのは 0.6% の標本、0.05 を超えて変わったのは 0.1%。新しいテストは修正なしでは落ちる。 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 2b81790..be1063e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -220,6 +220,13 @@ const CAMERA_DOUBT_FRAMES: f64 = 8.0; // below the camera), neither adds to the doubt nor clears it. const CAMERA_AGREE_GAP: f32 = 0.15; const CAMERA_AGREE_HOLD: f64 = 0.2; +// Once the eye server has read the eye open (at or above the doubt line) for this long, the doubt starts over and the +// eyelid goes back to the eye server's, agreeing or not. Without it an eye server that reads an open eye well below the +// camera (0.6-0.95 of its open reading on 0.4.x, VRCFT 0.59 through a fit, while the camera reads relaxed open: below +// CAMERA_AGREE_GAP of it) never agrees, so each blink's camera lag (median 44-67 ms over the 2026-10-08 and 10-16 +// recordings) adds up over minutes until the eyelid is the camera's, and blinks the camera misses go out open. A +// squint's jumps up last 100-150 ms, and its eyelid is raised to the camera's meanwhile anyway (raised_to_camera). +const CAMERA_DOUBT_CLEAR_OPEN: f64 = 1.0; // SteamOS 0.4.x's eye tracker often reads an open eye well below its open reading (1.000 on 0.4.3): 0.6-0.95, mostly // while looking down, at times for seconds straight ahead (mostly the right eye; 2026-10-07 23:23 and 10-08 20:47, // the camera seeing the eye open throughout). Through a fit that open at 1.000 and LID_FIT_CLOSED_MARGIN, a reading @@ -1191,7 +1198,8 @@ impl Smoother { /// and where each came from. An eye the camera sees closed is closed (the eye server misses one eye of many /// blinks). An eye the eye server has read closing (below CAMERA_DOUBT_BELOW or --blink-sync-below) while the camera /// saw it open for longer than a blink (CAMERA_DOUBT_GRACE, at least CAMERA_DOUBT_FRAMES of the camera's frames, - /// added up) takes the camera's eyelid, until the two have agreed for CAMERA_AGREE_HOLD. Before that the eye + /// added up) takes the camera's eyelid, until the two have agreed for CAMERA_AGREE_HOLD or the eye server has read + /// it open for CAMERA_DOUBT_CLEAR_OPEN. Before that the eye /// server's stands, so a blink the camera catches late (or not at all) still closes the eye, and the blink sync /// still closes an eye the eye server caught in one eye only. An eye without the camera's values is the eye /// server's, as before, and starts over. Whichever it is, an eye the camera sees open goes no lower than @@ -1238,6 +1246,12 @@ impl Smoother { !closing && valve >= camera.lid.min(LID_RELAXED) - CAMERA_AGREE_GAP }; state.diag = (valve, closing, agree); + if closing { + state.open_since = None; + } else if time - *state.open_since.get_or_insert(time) >= CAMERA_DOUBT_CLEAR_OPEN { + state.doubted = 0.0; + state.camera = false; + } if closing && !camera.closed { state.doubted += f64::from(dt); state.agree_since = None; @@ -1817,6 +1831,8 @@ struct LidDoubt { agree_since: Option, // Whether the eyelid is the camera's. camera: bool, + // Since when the eye server has read the eye open (CAMERA_DOUBT_CLEAR_OPEN). + open_since: Option, // For the replay's diagnostics (FEO_DOUBT_DIAG): the last sample's eye-server eyelid, whether it was closing (below // the doubt line), and whether the two agreed. diag: (f32, bool, bool), @@ -8902,6 +8918,36 @@ mod tests { assert_eq!(held, [lid(LID_RELAXED, false), lid(0.7, false)]); } + #[test] + fn blinks_far_apart_do_not_add_up_to_the_camera_s_eyelid() { + let settings = Settings::default(); + let mut smoother = Smoother::new(&settings); + let dt = 1.0 / 90.0; + let open = Some(CameraLid { lid: LID_RELAXED, closed: false, frame: 0.011 }); + let shut = Some(CameraLid { lid: 0.0, closed: true, frame: 0.011 }); + // A blink every 3 s: the eye server reads the eye shut for 14 samples, the camera from 4 samples later; between + // them the eye server reads the open eye at 0.59, below the camera's relaxed open by more than CAMERA_AGREE_GAP, + // so the two never agree. Each blink adds 4 samples of doubt, over CAMERA_DOUBT_GRACE in 7 blinks. + let mut time = 100.0; + for blink in 0..30 { + for i in 0..270 { + let valve = if i < 14 { 0.0 } else { 0.59 }; + let camera = if (4..18).contains(&i) { shut } else { open }; + let (_, from, _) = + smoother.check_lids(time, dt as f32, [valve, 0.59], [0.0; 2], [camera, open], [None; 2], &settings); + assert_ne!(from[0], LidFrom::CameraOpen, "blink {blink}, sample {i}"); + time += dt; + } + } + // One long closing the camera sees open still takes its eyelid + let mut from = [LidFrom::EyeServer; 2]; + for _ in 0..40 { + (_, from, _) = smoother.check_lids(time, dt as f32, [0.0, 0.59], [0.0; 2], [open; 2], [None; 2], &settings); + time += dt; + } + assert_eq!(from[0], LidFrom::CameraOpen); + } + /// 5 s of samples looking straight ahead, the eye server reading the eyes at `openness(sample index)`, the larger of /// the left eye's pre-fusion gaze variances at `variance(sample index)` (the right eye's 0.001), and the camera /// seeing the left eye closed over `cam` (else at relaxed open). From 84f7dcf16b30006ffd2bef403268a4b867f66bb0 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 20 Oct 2026 23:16:01 +0900 Subject: [PATCH 05/65] =?UTF-8?q?=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF?= =?UTF-8?q?=E6=8B=85=E5=BD=93=E3=81=AE=E6=8C=87=E6=91=98=E3=82=92=E7=9B=B4?= =?UTF-8?q?=E3=81=99:=20=E5=A4=A7=E3=81=8D=E3=81=8F=E3=81=AA=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E7=9E=B3=E3=82=92=E5=8F=96=E3=82=8A=E7=9B=B4=E3=81=99?= =?UTF-8?q?=E3=81=A8=E3=81=8D=E3=81=AE=E3=82=B2=E3=83=BC=E3=83=88=E3=80=81?= =?UTF-8?q?=E7=B4=B0=E3=82=81=E3=81=AE=E6=B7=B1=E3=81=95=E3=81=AE=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E9=99=90=E3=80=81=E4=B8=B8=E3=81=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eyecam: 大きくなった瞳を取り直したとき、開いた瞳の大きさを空にせず、その瞳の大きさ 30 個で埋めてから続ける。空のままだと 30 個たまるまで(90 fps で約 0.7 秒、15 fps で 2 秒)大きさのゲートが効かず、その間に目を閉じると閉じたまぶたのしわを瞳として取りえた - eyecam: 取り直す瞳は丸さも開いた瞳と同じものだけにする(閉じかけの横長のまつ毛の線を除く) - eyecam: ライブの細めの深さ(sq_depth)を、校正が f_sq にかける上下限(F_SQ_MAX・F_SQ_MIN)と同じ範囲に、使っている gap に対して収める。かぶりの gap が履歴の 0.6〜1.5 倍あるので、そのままだと深さが gap の 0.09〜2.25 倍まで出うる - eyecam: 追跡のやり直しで、大きくなった瞳の数も 0 に戻す - replay: FEO_CAM_FPS が 0 以下なら無視する(診断のみ) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/replay.rs | 2 +- tools/eyecam/src/live.rs | 24 +++++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/replay.rs b/src/replay.rs index da50a76..f65165f 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -213,7 +213,7 @@ struct Resampler { impl Resampler { fn from_env() -> Option { let read = |name: &str| std::env::var(name).ok().and_then(|value| value.parse::().ok()); - let fps = read("FEO_CAM_FPS"); + let fps = read("FEO_CAM_FPS").filter(|fps| *fps > 0.0); let delay = read("FEO_CAM_DELAY_MS").unwrap_or(0.0) / 1000.0; (fps.is_some() || delay > 0.0).then(|| Self { interval: fps.map_or(0.0, |fps| 1.0 / fps), diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index 7dc939d..bc61478 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -841,6 +841,7 @@ impl EyeEngine { self.prev = None; self.prev_p = None; self.nopupil = 0; + self.grown = 0; self.find_x_min = self.x_min; } @@ -975,7 +976,8 @@ impl EyeEngine { let open_box = self.levels.unlevel(self.box_open.value); let box_bright = self.box_open.len() >= 30 && self.levels.unlevel(f.box_mean) > 1.3 * open_box; let unanchored = no_prior || f.diag.retried; - let grown = p.is_some_and(|pp| { + // A dilated pupil keeps its roundness; a dark lash line on a closing eye is long and flat. + let grown = p.filter(|pp| { unanchored && !box_bright && self.a_open.len() >= 30 @@ -983,10 +985,18 @@ impl EyeEngine { && pp.a / self.a_open.value <= PUPIL_GROW_MAX && pp.inner <= self.inner_open.value + self.levels.step(15.0) && pp.vis >= 0.5 + && (self.ba_open.len() < 30 || (pp.b / pp.a - self.ba_open.value).abs() <= PUPIL_MAX_BA_DEV) }); - self.grown = if grown { self.grown + 1 } else { 0 }; - if self.grown >= frames_in(PUPIL_GROW_S, fps, 2) { + self.grown = if grown.is_some() { self.grown + 1 } else { 0 }; + if let Some(pp) = grown + && self.grown >= frames_in(PUPIL_GROW_S, fps, 2) + { + // Started over at this pupil's size, already full: the size check goes on at once from the new size (an + // empty median would leave every pupil unchecked, a crease on a closed lid too, until 30 were taken). self.a_open = LongMedian::new(20.0); + for _ in 0..30 { + self.a_open.push(pp.a, long_hz); + } self.grown = 0; } if let Some(pp) = p @@ -1146,8 +1156,11 @@ impl EyeEngine { let p0 = if p_typ.is_finite() { p_typ } else { DEFAULT_PITCH_N }; let apc = if pitch.is_finite() { ap_px + user.pitch_drop(pitch, p0) * r } else { ap_px }; let ap_cl_px = ap_n_px - gap_px; + // The calibrated depth within the limits the calibration puts on f_sq (F_SQ_MAX, F_SQ_MIN), here against the + // gap in use: a wear's gap 0.6..1.5 x the history's would otherwise put the squint's full depth anywhere from + // 0.09 to 2.25 gaps below open. let ap_sq = match user.sq_depth { - Some(d) => ap_n_px - d * r, + Some(d) => ap_n_px - (d * r).clamp((1.0 - F_SQ_MAX) * gap_px, (1.0 - F_SQ_MIN) * gap_px), None => ap_cl_px + user.f_sq * (ap_n_px - ap_cl_px), }; // Without a user calibration (squint depth, pitch curve) the aperture alone is not trusted: no squint, and the @@ -1709,7 +1722,8 @@ struct UserEye { /// A squint depth (`UserParams::f_sq`) at or above this is too shallow to tell from the open eye. pub const F_SQ_MAX: f64 = 0.85; /// A squint depth below this is deeper than the closed level by more than half the open gap: live, the squint -/// would only reach 1 / (1 - f_sq) < 0.67 by the time the eye reads closed. The user calibrations that passed +/// would only reach 1 / (1 - f_sq) < 0.67 by the time the eye reads closed (with `UserParams::sq_depth`, gap / +/// sq_depth against the gap in use, which live keeps within these same limits). The user calibrations that passed /// (2026-10-04..14) went down to -0.25 (the closed level of the wear calibration a little high); the ones below this /// were -0.78 and, replayed from recordings, -0.88 and -1.47. pub const F_SQ_MIN: f64 = -0.5; From b91c3da2c56a9389ebf85bad22b4ec63dbf9dae9 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 20 Oct 2026 23:19:16 +0900 Subject: [PATCH 06/65] =?UTF-8?q?eyecam:=20=E7=B4=B0=E3=82=81=E3=81=AE?= =?UTF-8?q?=E6=B7=B1=E3=81=95=E3=81=AE=E4=B8=8A=E4=B8=8B=E9=99=90=E3=82=92?= =?UTF-8?q?=20NaN=20=E3=81=A7=E8=90=BD=E3=81=A1=E3=81=AA=E3=81=84=E5=BD=A2?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88=E3=83=81=E3=82=A7=E3=83=83?= =?UTF-8?q?=E3=82=AF=E6=8B=85=E5=BD=93=E3=81=AE=E6=8C=87=E6=91=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f64::clamp は上下限が NaN だと panic する。履歴がなく(かぶり校正で見開きを取り逃がした初めての人)、瞳をまだ見つけていない最初のフレームでは虹彩の半径 r も gap も NaN なので、sq_depth 入りのユーザー校正があると eyecam が起動のたびに落ちていた。max/min にして、NaN のときは前と同じく細めの値を保つ。そのケースのテストを足した(clamp のままだと落ちる)。 Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/src/live.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index bc61478..5ce47fc 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -1158,9 +1158,10 @@ impl EyeEngine { let ap_cl_px = ap_n_px - gap_px; // The calibrated depth within the limits the calibration puts on f_sq (F_SQ_MAX, F_SQ_MIN), here against the // gap in use: a wear's gap 0.6..1.5 x the history's would otherwise put the squint's full depth anywhere from - // 0.09 to 2.25 gaps below open. + // 0.09 to 2.25 gaps below open. max/min, not clamp: before the iris radius is known (no history, no pupil yet) + // r and the gap are NaN, and clamp would panic; this stays NaN, and the squint keeps its last value as before. let ap_sq = match user.sq_depth { - Some(d) => ap_n_px - (d * r).clamp((1.0 - F_SQ_MAX) * gap_px, (1.0 - F_SQ_MIN) * gap_px), + Some(d) => ap_n_px - (d * r).max((1.0 - F_SQ_MAX) * gap_px).min((1.0 - F_SQ_MIN) * gap_px), None => ap_cl_px + user.f_sq * (ap_n_px - ap_cl_px), }; // Without a user calibration (squint depth, pitch curve) the aperture alone is not trusted: no squint, and the @@ -3570,6 +3571,23 @@ mod tests { assert!(e.r_est.len() > 0 || e.cm_n > 0, "what belongs to the wear stays"); } + #[test] + fn a_calibrated_squint_depth_without_a_history_or_a_pupil_does_not_panic() { + // A user calibration after a wear calibration that missed the widening (so nothing went into the history): + // on the next start, the frames before the first pupil have no iris radius at all. + let mut e = EyeEngine::default(); + let p = Params { user: Some(UserParams { sq_depth: Some(0.6), ..UserParams::default() }), ..Params::default() }; + let (blank, small) = (disc_frame(None), disc_frame(Some((250.0, 200.0, 20.0)))); + let mut t = 0.0; + for img in [&blank, &small] { + for _ in 0..60 { + t += 1.0 / FPS; + let out = e.process(img, f64::NAN, t, &p); + assert!((0.0..=1.0).contains(&out.eye_squint), "{}", out.eye_squint); + } + } + } + #[test] fn a_pupil_that_dilated_past_the_size_check_is_taken_again() { // The open pupil learnt at radius 20; after a blink (the search prior dropped) it comes back 1.5 x larger (the From 1a8b1368e935cd36468f5dae405308302765a1ca Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 20 Oct 2026 23:43:05 +0900 Subject: [PATCH 07/65] =?UTF-8?q?eyecam:=20=E5=8F=96=E3=82=8A=E7=9B=B4?= =?UTF-8?q?=E3=81=99=E5=A4=A7=E3=81=8D=E3=81=AA=E7=9E=B3=E3=81=AF=E3=80=8C?= =?UTF-8?q?=E3=81=84=E3=81=A4=E3=82=82=E3=82=88=E3=82=8A=E5=B9=B3=E3=81=9F?= =?UTF-8?q?=E3=81=84=E3=80=8D=E3=82=82=E3=81=AE=E3=81=A0=E3=81=91=E8=90=BD?= =?UTF-8?q?=E3=81=A8=E3=81=99=EF=BC=88=E6=A9=9F=E6=A2=B0=E5=AD=A6=E7=BF=92?= =?UTF-8?q?=E3=82=BB=E3=83=83=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E5=86=8D?= =?UTF-8?q?=E7=94=9F=E3=81=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4fa0f2a で足した丸さの条件が両側だったので、直しが効いていた唯一の録画(19-42-44 左目の 7.4 秒)がまた止まっていた。その瞳は暗くなって開いたのではなく、耳側に 40° 向いてカメラに正対し、1.4 倍に大きく、b/a 0.93 と丸くなっていた(いつもは 0.80)。平たい側だけで切るので、閉じかけのまつ毛の横長の線は今までどおり落ちる。再生(録画 26 本): 開いた目の瞳の検出 88.83 → 89.01%、1 秒以上の「大きさだけで捨てた」区間 1 → 0、閉じ・細め・見開きは同じ。max/min のコメントも正確にした。 Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/src/live.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index 5ce47fc..1efe8a7 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -45,10 +45,12 @@ const PUPIL_SETTLE_S: f64 = 0.3; /// for seconds. pub const PRIOR_RESET_S: f64 = 10.0 / FPS; /// A pupil the open-eye check drops only for its size (larger than PUPIL_MAX_GROW x the open pupil learnt, but no -/// more than PUPIL_GROW_MAX x, as dark and as whole as the open one, the iris box not as bright as lid skin) for this -/// long is a dilated pupil (the world got darker): it is taken, and the open pupil's size starts over from it. The -/// open pupil's size is a 20 s median of the pupils taken, so without this a pupil dilated past PUPIL_MAX_GROW x -/// before the median caught up was never taken again once a blink had dropped the search prior. +/// more than PUPIL_GROW_MAX x, as dark and as whole as the open one, no flatter, the iris box not as bright as lid +/// skin) for this long is a pupil that really looks larger (dilated as the world got darker, or turned toward the +/// camera): it is taken, and the open pupil's size starts over from it. The open pupil's size is a 20 s median of the +/// pupils taken, so without this a pupil grown past PUPIL_MAX_GROW x before the median caught up was never taken again +/// once a blink had dropped the search prior (one recording: 7.4 s without a pupil, the eye turned 40° toward the ear, +/// the pupil 1.4 x as large and rounder, b/a 0.93 against the usual 0.80). const PUPIL_GROW_S: f64 = 0.3; const PUPIL_MAX_GROW: f64 = 1.3; const PUPIL_GROW_MAX: f64 = 2.5; @@ -976,7 +978,8 @@ impl EyeEngine { let open_box = self.levels.unlevel(self.box_open.value); let box_bright = self.box_open.len() >= 30 && self.levels.unlevel(f.box_mean) > 1.3 * open_box; let unanchored = no_prior || f.diag.retried; - // A dilated pupil keeps its roundness; a dark lash line on a closing eye is long and flat. + // No flatter than the open pupil: a dark lash line on a closing eye is long and flat, while a pupil turned toward + // the camera grows rounder as it grows larger (so not the two-sided check of `roundness_ok`). let grown = p.filter(|pp| { unanchored && !box_bright @@ -985,7 +988,7 @@ impl EyeEngine { && pp.a / self.a_open.value <= PUPIL_GROW_MAX && pp.inner <= self.inner_open.value + self.levels.step(15.0) && pp.vis >= 0.5 - && (self.ba_open.len() < 30 || (pp.b / pp.a - self.ba_open.value).abs() <= PUPIL_MAX_BA_DEV) + && (self.ba_open.len() < 30 || pp.b / pp.a >= self.ba_open.value - PUPIL_MAX_BA_DEV) }); self.grown = if grown.is_some() { self.grown + 1 } else { 0 }; if let Some(pp) = grown @@ -1159,7 +1162,8 @@ impl EyeEngine { // The calibrated depth within the limits the calibration puts on f_sq (F_SQ_MAX, F_SQ_MIN), here against the // gap in use: a wear's gap 0.6..1.5 x the history's would otherwise put the squint's full depth anywhere from // 0.09 to 2.25 gaps below open. max/min, not clamp: before the iris radius is known (no history, no pupil yet) - // r and the gap are NaN, and clamp would panic; this stays NaN, and the squint keeps its last value as before. + // r, and with it the gap, is NaN, and clamp would panic; max/min keep it NaN (they only drop a NaN limit when the + // other side is a number), and the squint keeps its last value as before. let ap_sq = match user.sq_depth { Some(d) => ap_n_px - (d * r).max((1.0 - F_SQ_MAX) * gap_px).min((1.0 - F_SQ_MIN) * gap_px), None => ap_cl_px + user.f_sq * (ap_n_px - ap_cl_px), From 3a2f7700c95490dda841827819f11b8eb5a498b6 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Wed, 21 Oct 2026 21:41:34 +0900 Subject: [PATCH 08/65] =?UTF-8?q?0.8.2-beta.1:=20=E7=89=88=E3=82=92?= =?UTF-8?q?=E4=B8=8A=E3=81=92=E3=82=8B=EF=BC=88Cargo.toml=E3=83=BBCargo.lo?= =?UTF-8?q?ck=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6906fcd..7ef1d8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "frameeyeosc" -version = "0.8.1" +version = "0.8.2-beta.1" dependencies = [ "clap", "libc", diff --git a/Cargo.toml b/Cargo.toml index d70d1a5..431e607 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frameeyeosc" -version = "0.8.1" +version = "0.8.2-beta.1" edition = "2024" license = "MIT" description = "Sends Steam Frame eye tracking (gaze and eyelids) to VRChat over OSC" From 27d8702143b545ce26b5edd5fb1dc56ed56c4e61 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Wed, 21 Oct 2026 23:19:20 +0900 Subject: [PATCH 09/65] =?UTF-8?q?=E8=A6=96=E7=B7=9A:=20=E7=89=87=E7=9B=AE?= =?UTF-8?q?=E3=81=A0=E3=81=91=E9=96=89=E3=81=98=E3=81=A6=E8=AA=AD=E3=81=BE?= =?UTF-8?q?=E3=82=8C=E7=B6=9A=E3=81=91=E3=81=9F=E3=82=89=E6=AD=A2=E3=82=81?= =?UTF-8?q?=E3=81=9A=E3=81=AB=E3=82=82=E3=81=86=E7=89=87=E6=96=B9=E3=81=AE?= =?UTF-8?q?=E7=9B=AE=E3=81=A7=E5=8B=95=E3=81=8B=E3=81=99=EF=BC=88#32?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 片目だけ gaze_hold_below を下回る状態が 1 秒(ONE_EYE_HOLD_MAX_S)を超えたら、 その目の読みを疑って視線の保持をやめ、開いている目の視線で動かす。 両目が下回る間(まばたき)は今まで通り保持し、タイマーはその目が線を上回ったら やり直す(まばたきではやり直さない)。v1/v2・independent_eyes のどれでも同じ。 Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- src/main.rs | 168 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 158 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6ae6c54..7185ef7 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Settings are in `~/.config/frameeyeosc/config.json`. The panel writes it, and yo | `gaze_beta` | `--gaze-beta` | `1.5` | Higher = follows fast eye movements with less lag, and settles sooner after one | | `gaze_d_cutoff` | `--gaze-d-cutoff` | `0.5` | Lower = tracker noise loosens the gaze filter less, and the gaze glides softly into a quick eye movement instead of snapping to it | | `gaze_deadzone` | `--gaze-deadzone` | `0.005` | Gaze changes smaller than this are ignored (1.0 = 45°); the gaze can stop up to this far short of where the eyes landed | -| `gaze_hold_below` | `--gaze-hold-below` | `0.5` | Hold the gaze while either eye's openness is below this; `0` turns it off | +| `gaze_hold_below` | `--gaze-hold-below` | `0.5` | Hold the gaze while either eye's openness is below this; `0` turns it off. One eye alone below it holds the gaze for up to 1 s (a wink); longer, and that eye's reading is taken as wrong and the other eye moves the gaze | | `independent_eyes` | `--independent-eyes` | `false` | Instead of the shared gaze for both eyes, turn the eyes in by a fixed 2° between them (as if looking about 2 m away), around the shared sideways gaze; each eye keeps its own up/down, and an eye whose gaze is unreliable takes the other's. The Frame's own per-eye sideways gaze isn't used: with the eyes on a dot 0.9 m away (4.4° between them) it read 0.1-17.3° apart, and while recording it swung between under 1° and over 4° 20-32 times a minute (the avatar went cross-eyed, 13-19° at the p95). Intentional cross-eye still shows: Valve's left - right is let through once it has stayed at 20° or more for 0.5 s (or 25° or more for 0.25 s) with both eyes at least 0.7 open and looking no more than 15° down, having got there within 2.5 s of being below 15° and not right after a blink (0.3 s), until it stays below 15° for 0.15 s. In a wear where Valve reads the eyes turned in all along, the three lines go up together to 10° above the median of its last 60 s. While the "Track Dominant Eye Only" setting is on, both eyes get the tracked eye's gaze either way | | `eye_behavior` | `--eye-behavior` | `2` | How the eyes move: `2` as now (v2), `1` as up to 0.7.5 (v1: each eye's own gaze from Valve as it is, and none of the eyelid rules added after 0.7.5: the eye cameras' closing, open and widening rules, `lid_open_snap`, `camera_lid_floor`, how open an eye looking far down is expected to be). Switched on the panel's Advanced › Version page. See [Going back to v1](#going-back-to-v1-or-to-an-earlier-release) | | `gaze_quality_limit` | `--gaze-quality-limit` | `0` (off) | Optional safety net: ignore an eye's gaze while the Frame's own uncertainty (covariance) for it is above this (for example `0.03`). The other eye moves both, and if both are above it the gaze is held. Eyelids aren't affected. On a well-fitted headset it made no measurable difference, because the uncertainty only rises while the eyes are mostly shut, where `gaze_hold_below` already holds the gaze | diff --git a/src/main.rs b/src/main.rs index be1063e..ebdbc70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,6 +63,12 @@ const MAX_GAP: f64 = 0.25; const LID_D_CUTOFF: f32 = 1.0; // The sideways gaze hold (--gaze-down-hold-x-deg) fades in over this many degrees further down. const DOWN_HOLD_FADE_DEG: f32 = 10.0; +// One eye read below --gaze-hold-below while the other is not (a wink, or one eye crossing the line a little before +// the other as a blink starts) holds the gaze for up to this long (s, see OneEyeLow); longer, and that eye's reading +// is taken as wrong: it no longer holds the gaze, and its gaze is left out, so the other eye moves both. SteamOS can +// read one open eye at about 0.06 for minutes to hours on end (the other 1.1, the eye camera reading it open), and the +// gaze stood still all that time. +const ONE_EYE_HOLD_MAX_S: f64 = 1.0; // With --independent-eyes, how far the eyes turn in from each other (degrees, both together): the eyes on something // about 2 m away. Fixed, because the Frame's own per-eye sideways gaze can't show it: on 2026-10-08, with the eyes on // a fit dot 0.9 m away (4.4° at a 69.5 mm IPD), its left - right read 0.1-1.1° looking up, 0.6-17.3° straight ahead @@ -629,7 +635,8 @@ struct Args { /// Gaze changes smaller than this (1.0 = 45°) are ignored so the eyes stay put while fixating #[arg(long, default_value_t = 0.005)] gaze_deadzone: f32, - /// Hold the gaze while either eye's Frame openness is below this; 0 disables + /// Hold the gaze while either eye's Frame openness is below this (one eye alone for up to a second; longer, and + /// the other eye moves the gaze); 0 disables #[arg(long, default_value_t = 0.5)] gaze_hold_below: f32, /// Turn the eyes in a little toward each other (about 2 m away) instead of sending the combined gaze for both eyes; @@ -992,6 +999,8 @@ struct Smoother { last_gaze: [Option<[f32; 2]>; 3], // The up/down gaze the eyelid fit last used, kept while the gaze is held. lid_vertical: Option, + // How long each eye has been read alone below --gaze-hold-below (see ONE_EYE_HOLD_MAX_S). + one_eye_low: OneEyeLow, // Left, right and combined raw x from the last sample above --gaze-down-hold-x-deg. down_hold_x: Option<[f32; 3]>, // Per eye: since when its eyelid has been above relaxed open (see sustain_widen), and when it last was. @@ -1032,6 +1041,7 @@ impl Smoother { last_time: None, last_gaze: [None; 3], lid_vertical: None, + one_eye_low: OneEyeLow::default(), down_hold_x: None, widen_sustain: WidenSustain::default(), saturation: Saturation::default(), @@ -1143,6 +1153,7 @@ impl Smoother { self.last_time = None; self.last_gaze = [None; 3]; self.lid_vertical = None; + self.one_eye_low = OneEyeLow::default(); self.down_hold_x = None; self.widen_sustain = WidenSustain::default(); self.camera = CameraSmoother::default(); @@ -1346,6 +1357,46 @@ impl Smoother { } out } + + /// Per eye, whether its Frame openness below --gaze-hold-below (`line`) is taken as wrong: it is the only one below + /// it, and has been for longer than ONE_EYE_HOLD_MAX_S since it last read at or above it. Such an eye neither holds + /// the gaze (shut_for_gaze) nor gives it. + fn doubt_one_low_eye(&mut self, time: f64, openness: [f32; 2], line: f32) -> [bool; 2] { + self.one_eye_low.step(time, openness.map(|openness| openness < line)) + } +} + +/// How long each eye has been read alone below --gaze-hold-below (see ONE_EYE_HOLD_MAX_S). Both eyes below it (a +/// blink) neither adds to that nor starts it over: an eye SteamOS keeps reading shut is not held for another +/// ONE_EYE_HOLD_MAX_S after every blink, and an eye that crosses the line a little before the other at both ends of a +/// long closure counts only those moments. +#[derive(Default)] +struct OneEyeLow { + alone: [f64; 2], + last_time: Option, +} + +impl OneEyeLow { + /// Per eye, whether it is doubted, given which eyes read below the line (`low`) at `time`. + fn step(&mut self, time: f64, low: [bool; 2]) -> [bool; 2] { + let dt = self.last_time.map_or(0.0, |last| (time - last).clamp(0.0, MAX_GAP)); + self.last_time = Some(time); + [0, 1].map(|eye| { + let alone = low[eye] && !low[1 - eye]; + if !low[eye] { + self.alone[eye] = 0.0; + } else if alone { + self.alone[eye] += dt; + } + alone && self.alone[eye] > ONE_EYE_HOLD_MAX_S + }) + } +} + +/// Whether the eyes are shut enough to hold the gaze: either one's Frame openness below --gaze-hold-below, but for an +/// eye whose reading is `doubted` (Smoother::doubt_one_low_eye). +fn shut_for_gaze(openness: [f32; 2], doubted: [bool; 2], settings: &Settings) -> bool { + (0..2).any(|eye| openness[eye] < settings.gaze_hold_below && !doubted[eye]) } /// Intentional cross-eye for --independent-eyes: Valve's own left - right is let through only while it stays far beyond @@ -3552,7 +3603,8 @@ fn process( if blink_stages { shut_lids(&mut lids, shut); } - let shut_eyes = data.openness.iter().any(|openness| *openness < settings.gaze_hold_below); + let doubted = smoother.doubt_one_low_eye(data.sample_time, data.openness, settings.gaze_hold_below); + let shut_eyes = shut_for_gaze(data.openness, doubted, settings); let brows = brows_for(settings, smoother, data.sample_time, None, before_cap, lids, mapped); (gaze, unscaled_gaze, lids, shut_eyes, openness_scaled, camera, lid_from, close_held, brows) } else { @@ -3566,9 +3618,12 @@ fn process( // With --eye-behavior 2 short gaps in the camera's eyelid are bridged (see CAMERA_GAP_HOLD) let (camera_lids, held) = if v2 { smoother.steady_camera(data.sample_time, camera_lids) } else { (camera_lids, [None; 2]) }; - // Before anything else, so the filters see the gaze the way it will be sent. - let open = data.openness.iter().all(|openness| *openness >= settings.gaze_hold_below); - let trusted = reliable.map(|reliable| reliable && open); + // Before anything else, so the filters see the gaze the way it will be sent. An eye read alone below + // --gaze-hold-below for longer than a wink counts as unreliable instead (ONE_EYE_HOLD_MAX_S) + let doubted = smoother.doubt_one_low_eye(data.sample_time, data.openness, settings.gaze_hold_below); + let usable_gaze = [0, 1].map(|eye| reliable[eye] && !doubted[eye]); + let open = !shut_for_gaze(data.openness, doubted, settings); + let trusted = usable_gaze.map(|usable| usable && open); let corrected = correct_gaze(smoother.hold_down_x(raw_gaze, trusted, settings), settings); let readings: [f32; 8] = std::array::from_fn(|i| if i < 6 { corrected[i] } else { data.openness[i - 6] }); @@ -3577,15 +3632,19 @@ fn process( let readings = if settings.despike { despiked } else { readings }; let angles: [f32; 6] = std::array::from_fn(|i| readings[i]); let openness = [readings[6], readings[7]]; - let mut gaze = choose_gaze(angles, reliable, settings.independent_eyes, v2); + let mut gaze = choose_gaze(angles, usable_gaze, settings.independent_eyes, v2); // One unreliable eye takes the other's gaze (choose_gaze), so only both being unreliable holds it; with - // --eye-behavior 1 each eye's own gaze is held while it is unreliable - let hold = if openness.iter().any(|openness| *openness < settings.gaze_hold_below) - || reliable == [false; 2] - { + // --eye-behavior 1 each eye's own gaze is held while it is unreliable, but a doubted eye looks where the other + // one does (its own gaze is no better than its openness) + let hold = if shut_for_gaze(openness, doubted, settings) || usable_gaze == [false; 2] { [true; 3] } else if settings.independent_eyes && !v2 { - [!reliable[0], !reliable[1], false] + for eye in (0..2).filter(|eye| doubted[*eye]) { + let other = 1 - eye; + gaze[2 * eye] = gaze[2 * other]; + gaze[2 * eye + 1] = gaze[2 * other + 1]; + } + [!usable_gaze[0] && !doubted[0], !usable_gaze[1] && !doubted[1], false] } else { [false; 3] }; @@ -6091,6 +6150,93 @@ mod tests { assert!(raw[10].gaze_held && !raw[12].gaze_held); } + /// Both eyes looking along (x, 0) at sample `index`, but the left eye's own gaze off up and to the left (as an eye + /// read shut may give), with the given Frame openness. + fn left_eye_off(index: usize, x: f32, openness: [f32; 2]) -> EyeData { + let mut data = reading(index, [x, 0.0], openness); + data.gaze[0] = [-0.4, 0.3, -1.0]; + data + } + + /// The x the eyes look along: straight ahead, then over to 0.4 from sample `from` to 60 samples later. + fn sweep(index: usize, from: usize) -> f32 { + 0.4 * (index.saturating_sub(from) as f32 / 60.0).min(1.0) + } + + #[test] + fn one_eye_read_shut_for_long_lets_the_other_move_the_gaze() { + // The left eye read at 0.06 from sample 10 while the right reads 1.1 (as SteamOS can for hours), the eyes + // looking over to the right from sample 100. + let readings: Vec = (0..400) + .map(|i| left_eye_off(i, sweep(i, 100), if i < 10 { [1.1; 2] } else { [0.06, 1.1] })) + .collect(); + let expected = gaze_angles([0.4, 0.0, -1.0])[0]; + for eye_behavior in [1, 2] { + for independent_eyes in [false, true] { + let settings = Settings { eye_behavior, independent_eyes, ..settings() }; + let sent = run(&settings, &readings); + let case = format!("v{eye_behavior}, independent {independent_eyes}"); + // Held through the first ONE_EYE_HOLD_MAX_S, as a wink... + assert!(sent[20..90].iter().all(|sample| sample.gaze_held), "{case}"); + // ...then the right eye moves both, the left eye's own gaze left out. + assert!(sent[105..].iter().all(|sample| !sample.gaze_held), "{case}"); + let half = if independent_eyes && eye_behavior == 2 { VERGENCE_DEG / 2.0 / 45.0 } else { 0.0 }; + let wanted = [expected + half, 0.0, expected - half, 0.0, expected, 0.0]; + let gaze = sent.last().unwrap().gaze; + for (value, wanted) in gaze.into_iter().zip(wanted) { + assert!((value - wanted).abs() < 0.02, "{case}: {gaze:?}"); + } + } + } + // The same in --raw (where the gaze is never held, only said to be). + let raw = run(&Settings { raw: true, ..settings() }, &readings); + assert!(raw[20..90].iter().all(|sample| sample.gaze_held) && raw[105..].iter().all(|sample| !sample.gaze_held)); + // --gaze-hold-below 0 still holds nothing. + let off = run(&Settings { gaze_hold_below: 0.0, ..settings() }, &readings); + assert!(off.iter().all(|sample| !sample.gaze_held)); + } + + #[test] + fn one_eye_read_shut_for_a_moment_still_holds_the_gaze() { + // A 0.5 s wink of the left eye while the eyes look over to the right: held as it was, then let go. + let readings: Vec = (0..150) + .map(|i| left_eye_off(i, sweep(i, 60), if (50..95).contains(&i) { [0.06, 1.1] } else { [1.1; 2] })) + .collect(); + let sent = run(&settings(), &readings); + assert!(sent[51..95].iter().all(|sample| sample.gaze_held && sample.gaze == sent[50].gaze)); + assert!(sent[97..].iter().all(|sample| !sample.gaze_held)); + } + + #[test] + fn both_eyes_read_shut_hold_the_gaze_however_long() { + // Both eyes shut for 3 s, the gaze jumping about meanwhile. + let readings: Vec = (0..400) + .map(|i| left_eye_off(i, sweep(i, 60), if (50..320).contains(&i) { [0.06, 0.2] } else { [1.1; 2] })) + .collect(); + let sent = run(&settings(), &readings); + assert!(sent[51..320].iter().all(|sample| sample.gaze_held && sample.gaze == sent[50].gaze)); + assert!(sent[322..].iter().all(|sample| !sample.gaze_held)); + } + + #[test] + fn the_one_eye_hold_starts_over_once_that_eye_opens_but_not_with_a_blink() { + // The left eye read shut for 0.8 s, open for 0.1 s, and shut for 0.9 s again: held all along, since each + // stretch is shorter than ONE_EYE_HOLD_MAX_S. + let openness = |i: usize| if (50..122).contains(&i) || (131..212).contains(&i) { [0.06, 1.1] } else { [1.1; 2] }; + let readings: Vec = (0..260).map(|i| left_eye_off(i, sweep(i, 60), openness(i))).collect(); + let sent = run(&settings(), &readings); + assert!(sent[133..212].iter().all(|sample| sample.gaze_held)); + assert!(sent[214..].iter().all(|sample| !sample.gaze_held)); + // Read shut for good: a blink (both eyes) holds the gaze as ever, but afterwards it is not held for another + // ONE_EYE_HOLD_MAX_S. + let openness = |i: usize| if (200..215).contains(&i) { [0.06, 0.2] } else { [0.06, 1.1] }; + let readings: Vec = (0..300).map(|i| left_eye_off(i, sweep(i, 100), openness(i))).collect(); + let sent = run(&settings(), &readings); + assert!(sent[150..200].iter().all(|sample| !sample.gaze_held)); + assert!(sent[201..215].iter().all(|sample| sample.gaze_held)); + assert!(sent[217..].iter().all(|sample| !sample.gaze_held)); + } + #[test] fn each_eye_may_have_its_own_sideways_fit() { let angles = [0.2, 0.1, -0.2, 0.1, 0.0, 0.1]; From 36253c5a1dbe500bb89777c902d921d172417664 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Wed, 21 Oct 2026 23:23:54 +0900 Subject: [PATCH 10/65] =?UTF-8?q?README.ja:=20=E7=89=87=E7=9B=AE=E3=81=A0?= =?UTF-8?q?=E3=81=91=E9=96=89=E3=81=98=E3=81=A6=E8=AA=AD=E3=81=BE=E3=82=8C?= =?UTF-8?q?=E7=B6=9A=E3=81=91=E3=82=8B=E3=81=A8=E3=81=8D=E3=81=AE=E8=A6=96?= =?UTF-8?q?=E7=B7=9A=E3=81=AE=E6=89=B1=E3=81=84=E3=82=92=E6=9B=B8=E3=81=8D?= =?UTF-8?q?=E8=B6=B3=E3=81=99=EF=BC=88#32=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- README.ja.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.ja.md b/README.ja.md index 982b77c..fefbf17 100644 --- a/README.ja.md +++ b/README.ja.md @@ -199,7 +199,7 @@ Frame のほかのアプリ(コントローラーのボタンなど)から | `gaze_beta` | `--gaze-beta` | `1.5` | 上げるほど素早い視線の動きに遅れず付いていき、動いたあと早く落ち着く | | `gaze_d_cutoff` | `--gaze-d-cutoff` | `0.5` | 下げるほど、トラッキングのノイズで視線のフィルタがゆるみにくく、素早い目の動きにもパッと飛ばずにやわらかく付いていく | | `gaze_deadzone` | `--gaze-deadzone` | `0.005` | これより小さい視線の変化は無視(1.0=45°)。視線は目が止まった所からこの分だけ手前で止まることがある | -| `gaze_hold_below` | `--gaze-hold-below` | `0.5` | どちらかの目の開き具合がこれより小さい間は視線を止める。`0` で無効 | +| `gaze_hold_below` | `--gaze-hold-below` | `0.5` | どちらかの目の開き具合がこれより小さい間は視線を止める。`0` で無効。片目だけ下回っているときは 1 秒まで(ウインク)止め、それより長いとその目の読みは誤りとみなして、もう片方の目で視線を動かす | | `independent_eyes` | `--independent-eyes` | `false` | 両目に同じ視線を送る代わりに、共通の左右の向きから両目を決まった 2°(2 m ほど先を見ているくらい)だけ寄せる。上下は目ごと、視線が不確かな目はもう片方の目のものを使う。Frame の目ごとの左右の値は使わない: 0.9 m 先の点(両目の差 4.4°)を見ても差が 0.1〜17.3° と読め、録画中も 1° 未満と 4° 超えのあいだを 1 分に 20〜32 回行き来していた(アバターが寄り目になり、p95 で 13〜19°)。わざと寄り目にしたときは見せる: 両目が 0.7 以上開き、15° より深く下を見ていないまま、Valve の左右差が 20° 以上を 0.5 秒(または 25° 以上を 0.25 秒)続けたら通し、15° 未満が 0.15 秒続いたら戻す。15° 未満から 2.5 秒以内に上がったときだけで、まばたきのすぐあと(0.3 秒)は数えない。Valve がずっと寄り気味に読むかぶりでは、3 つの線をまとめて、直近 60 秒の中央値より 10° 上まで上げる。「Track Dominant Eye Only」の設定がオンのあいだは、どちらでも両目とも追っている目の視線になる | | `eye_behavior` | `--eye-behavior` | `2` | 目の動かし方: `2` 今の動き(v2)、`1` 0.7.5 までの動き(v1。Valve の目ごとの視線をそのまま、0.7.5 の後に足したまぶたの決まり(目のカメラの閉じ・開き・見開き、`lid_open_snap`、`camera_lid_floor`、深く下を見たときの開き具合)を使わない)。パネルの「詳細」›「バージョン」で切り替えられる。[目の動かし方を v1 に戻す](#目の動かし方を-v1-に戻す前の版に戻す) | | `gaze_quality_limit` | `--gaze-quality-limit` | `0`(オフ) | 念のための安全策: Frame が出す視線の不確かさ(共分散)がこれ(例: `0.03`)より大きい目の視線は使わない。片目だけならもう片方の目で両目を動かし、両目ともなら視線を止める。まぶたには影響しない。きちんと合ったヘッドセットでは測って差が出なかった。不確かさが上がるのはほぼ目を閉じかけている間だけで、そこは `gaze_hold_below` がもう視線を止めているため | From 1e4eef6ac58fe59c38b55386e8c15370a9349c0b Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Wed, 21 Oct 2026 23:24:55 +0900 Subject: [PATCH 11/65] =?UTF-8?q?=E8=A6=96=E7=B7=9A:=20=E7=89=87=E7=9B=AE?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=9E=E3=83=BC=E3=81=AF=201=20=E3=82=B5?= =?UTF-8?q?=E3=83=B3=E3=83=97=E3=83=AB=E3=81=A0=E3=81=91=E9=96=8B=E3=81=84?= =?UTF-8?q?=E3=81=A6=E8=AA=AD=E3=81=BE=E3=82=8C=E3=81=A6=E3=82=82=E6=88=BB?= =?UTF-8?q?=E3=81=95=E3=81=AA=E3=81=84=EF=BC=88=E3=83=81=E3=82=A7=E3=83=83?= =?UTF-8?q?=E3=82=AF=E6=8B=85=E5=BD=93=E3=81=AE=E6=8C=87=E6=91=98=E3=80=81?= =?UTF-8?q?#32=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit despike が落とす 1 サンプルの飛びでタイマーが 0 に戻り、そのあとまた 1 秒止まっていた。 線以上が 2 サンプル続いたときだけやり直す。 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main.rs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index ebdbc70..1f4b6ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1369,10 +1369,12 @@ impl Smoother { /// How long each eye has been read alone below --gaze-hold-below (see ONE_EYE_HOLD_MAX_S). Both eyes below it (a /// blink) neither adds to that nor starts it over: an eye SteamOS keeps reading shut is not held for another /// ONE_EYE_HOLD_MAX_S after every blink, and an eye that crosses the line a little before the other at both ends of a -/// long closure counts only those moments. +/// long closure counts only those moments. It starts over once the eye reads at or above the line for two samples in a +/// row: one sample up is a spike the despike stage drops (the hold still sees the eye shut), so it keeps the count. #[derive(Default)] struct OneEyeLow { alone: [f64; 2], + up: [u8; 2], last_time: Option, } @@ -1382,13 +1384,18 @@ impl OneEyeLow { let dt = self.last_time.map_or(0.0, |last| (time - last).clamp(0.0, MAX_GAP)); self.last_time = Some(time); [0, 1].map(|eye| { - let alone = low[eye] && !low[1 - eye]; - if !low[eye] { - self.alone[eye] = 0.0; - } else if alone { - self.alone[eye] += dt; + if low[eye] { + self.up[eye] = 0; + if !low[1 - eye] { + self.alone[eye] += dt; + } + } else { + self.up[eye] = self.up[eye].saturating_add(1); + if self.up[eye] >= 2 { + self.alone[eye] = 0.0; + } } - alone && self.alone[eye] > ONE_EYE_HOLD_MAX_S + !low[1 - eye] && self.alone[eye] > ONE_EYE_HOLD_MAX_S }) } } @@ -6235,6 +6242,11 @@ mod tests { assert!(sent[150..200].iter().all(|sample| !sample.gaze_held)); assert!(sent[201..215].iter().all(|sample| sample.gaze_held)); assert!(sent[217..].iter().all(|sample| !sample.gaze_held)); + // One sample read open (dropped by the despike stage) does not start it over either. + let openness = |i: usize| if i == 200 { [1.1; 2] } else { [0.06, 1.1] }; + let readings: Vec = (0..300).map(|i| left_eye_off(i, sweep(i, 100), openness(i))).collect(); + let sent = run(&settings(), &readings); + assert!(sent[150..].iter().all(|sample| !sample.gaze_held)); } #[test] From 1fbb2f9690b1e13649699eae655dbbbcef03f1ea Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Wed, 21 Oct 2026 23:52:20 +0900 Subject: [PATCH 12/65] =?UTF-8?q?=E8=A6=96=E7=B7=9A:=20=E7=9B=AE=E3=81=94?= =?UTF-8?q?=E3=81=A8=E3=81=AE=E8=A6=96=E7=B7=9A=E3=82=92=E3=81=A9=E3=82=8C?= =?UTF-8?q?=E3=81=A0=E3=81=91=E4=BD=BF=E3=81=86=E3=81=8B=E3=82=92=E9=81=B8?= =?UTF-8?q?=E3=81=B9=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= =?UTF-8?q?=EF=BC=88v2=20=E3=81=AE=E5=B7=A6=E5=8F=B3=E5=88=A5=E3=80=85?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit own_eye_gaze(--own-eye-gaze、0〜1、既定 0)。v2 で independent_eyes のとき、 目ごとの左右の視線を寄せた視線からどれだけ目ごとの値に近づけるか。 フィルターの前に混ぜ、寄り目の通し分は (1 - own_eye_gaze) だけ足す。 0 はこれまでと同じ値、1 は目ごとの値そのまま。視線が不確かな目は もう片方の目のものを使う。パネルは視線 › 動く範囲に − / + の行を足す。 Co-Authored-By: Claude Opus 5.5 (1M context) --- README.ja.md | 1 + README.md | 1 + panel/README.md | 2 +- panel/src/config.cpp | 1 + panel/src/config.h | 2 + panel/src/i18n.cpp | 4 + panel/src/i18n.h | 2 + panel/src/panel.cpp | 25 ++++++ panel/tests/model_test.cpp | 31 +++++++ panel/tests/panel_test.cpp | 45 ++++++++++ src/config.rs | 26 ++++++ src/main.rs | 166 ++++++++++++++++++++++++++++++++++++- 12 files changed, 302 insertions(+), 4 deletions(-) diff --git a/README.ja.md b/README.ja.md index fefbf17..799bd98 100644 --- a/README.ja.md +++ b/README.ja.md @@ -201,6 +201,7 @@ Frame のほかのアプリ(コントローラーのボタンなど)から | `gaze_deadzone` | `--gaze-deadzone` | `0.005` | これより小さい視線の変化は無視(1.0=45°)。視線は目が止まった所からこの分だけ手前で止まることがある | | `gaze_hold_below` | `--gaze-hold-below` | `0.5` | どちらかの目の開き具合がこれより小さい間は視線を止める。`0` で無効。片目だけ下回っているときは 1 秒まで(ウインク)止め、それより長いとその目の読みは誤りとみなして、もう片方の目で視線を動かす | | `independent_eyes` | `--independent-eyes` | `false` | 両目に同じ視線を送る代わりに、共通の左右の向きから両目を決まった 2°(2 m ほど先を見ているくらい)だけ寄せる。上下は目ごと、視線が不確かな目はもう片方の目のものを使う。Frame の目ごとの左右の値は使わない: 0.9 m 先の点(両目の差 4.4°)を見ても差が 0.1〜17.3° と読め、録画中も 1° 未満と 4° 超えのあいだを 1 分に 20〜32 回行き来していた(アバターが寄り目になり、p95 で 13〜19°)。わざと寄り目にしたときは見せる: 両目が 0.7 以上開き、15° より深く下を見ていないまま、Valve の左右差が 20° 以上を 0.5 秒(または 25° 以上を 0.25 秒)続けたら通し、15° 未満が 0.15 秒続いたら戻す。15° 未満から 2.5 秒以内に上がったときだけで、まばたきのすぐあと(0.3 秒)は数えない。Valve がずっと寄り気味に読むかぶりでは、3 つの線をまとめて、直近 60 秒の中央値より 10° 上まで上げる。「Track Dominant Eye Only」の設定がオンのあいだは、どちらでも両目とも追っている目の視線になる | +| `own_eye_gaze` | `--own-eye-gaze` | `0` | v2 で `independent_eyes` のとき、Valve の目ごとの左右の視線をどれだけ使うか、`0`〜`1`。`0` で上の寄せた視線、`1` で目ごとの値をそのまま(上のとおり大きく揺れるので、ぶれる)、あいだはその割合だけ寄せる。まぶた・なめらかさ・止めるのは v2 のまま、視線が不確かな目はもう片方の目のものを使う。v1 はもともと目ごとの視線なので効かない。視線タブの「動く範囲」の「目ごとの視線」 | | `eye_behavior` | `--eye-behavior` | `2` | 目の動かし方: `2` 今の動き(v2)、`1` 0.7.5 までの動き(v1。Valve の目ごとの視線をそのまま、0.7.5 の後に足したまぶたの決まり(目のカメラの閉じ・開き・見開き、`lid_open_snap`、`camera_lid_floor`、深く下を見たときの開き具合)を使わない)。パネルの「詳細」›「バージョン」で切り替えられる。[目の動かし方を v1 に戻す](#目の動かし方を-v1-に戻す前の版に戻す) | | `gaze_quality_limit` | `--gaze-quality-limit` | `0`(オフ) | 念のための安全策: Frame が出す視線の不確かさ(共分散)がこれ(例: `0.03`)より大きい目の視線は使わない。片目だけならもう片方の目で両目を動かし、両目ともなら視線を止める。まぶたには影響しない。きちんと合ったヘッドセットでは測って差が出なかった。不確かさが上がるのはほぼ目を閉じかけている間だけで、そこは `gaze_hold_below` がもう視線を止めているため | | `despike` | `--no-despike` | `true` | 視線と開き具合の 1 サンプルだけの途切れを消す(3 サンプルの中央値。全体が約 11 ms 遅れる) | diff --git a/README.md b/README.md index 7185ef7..f7952ae 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ Settings are in `~/.config/frameeyeosc/config.json`. The panel writes it, and yo | `gaze_deadzone` | `--gaze-deadzone` | `0.005` | Gaze changes smaller than this are ignored (1.0 = 45°); the gaze can stop up to this far short of where the eyes landed | | `gaze_hold_below` | `--gaze-hold-below` | `0.5` | Hold the gaze while either eye's openness is below this; `0` turns it off. One eye alone below it holds the gaze for up to 1 s (a wink); longer, and that eye's reading is taken as wrong and the other eye moves the gaze | | `independent_eyes` | `--independent-eyes` | `false` | Instead of the shared gaze for both eyes, turn the eyes in by a fixed 2° between them (as if looking about 2 m away), around the shared sideways gaze; each eye keeps its own up/down, and an eye whose gaze is unreliable takes the other's. The Frame's own per-eye sideways gaze isn't used: with the eyes on a dot 0.9 m away (4.4° between them) it read 0.1-17.3° apart, and while recording it swung between under 1° and over 4° 20-32 times a minute (the avatar went cross-eyed, 13-19° at the p95). Intentional cross-eye still shows: Valve's left - right is let through once it has stayed at 20° or more for 0.5 s (or 25° or more for 0.25 s) with both eyes at least 0.7 open and looking no more than 15° down, having got there within 2.5 s of being below 15° and not right after a blink (0.3 s), until it stays below 15° for 0.15 s. In a wear where Valve reads the eyes turned in all along, the three lines go up together to 10° above the median of its last 60 s. While the "Track Dominant Eye Only" setting is on, both eyes get the tracked eye's gaze either way | +| `own_eye_gaze` | `--own-eye-gaze` | `0` | With `independent_eyes` in v2, how much of each eye's own sideways gaze from Valve is used, `0`..`1`: `0` the turned-in gaze above, `1` each eye's own as Valve reads it (wobbly, since it swings as above), in between that far from one to the other. Eyelids, smoothing and holding stay v2's, and an eye whose gaze is unreliable still takes the other's. No effect in v1, which sends each eye's own gaze anyway. "Own gaze per eye" on the Gaze tab's "Reach" page | | `eye_behavior` | `--eye-behavior` | `2` | How the eyes move: `2` as now (v2), `1` as up to 0.7.5 (v1: each eye's own gaze from Valve as it is, and none of the eyelid rules added after 0.7.5: the eye cameras' closing, open and widening rules, `lid_open_snap`, `camera_lid_floor`, how open an eye looking far down is expected to be). Switched on the panel's Advanced › Version page. See [Going back to v1](#going-back-to-v1-or-to-an-earlier-release) | | `gaze_quality_limit` | `--gaze-quality-limit` | `0` (off) | Optional safety net: ignore an eye's gaze while the Frame's own uncertainty (covariance) for it is above this (for example `0.03`). The other eye moves both, and if both are above it the gaze is held. Eyelids aren't affected. On a well-fitted headset it made no measurable difference, because the uncertainty only rises while the eyes are mostly shut, where `gaze_hold_below` already holds the gaze | | `despike` | `--no-despike` | `true` | Remove one-sample glitches in gaze and openness (median of 3 samples; everything arrives ~11 ms later) | diff --git a/panel/README.md b/panel/README.md index 6f6221a..d97e453 100644 --- a/panel/README.md +++ b/panel/README.md @@ -27,7 +27,7 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ |---|---| | 基本 | 送信(送る / 止める)、送り先(3 枚のカード: VRChat に直接 / VRCFT(LiveLink)「おすすめ」 / VRCFT(FrameEyeOSC)「全部入り」。タグはカードの上の縁に。それぞれ 2 行で 同期・PC に何が要るか(`outputMarks[4][2]`。見開きの行は無い)。FrameEyeOSC の同期は日本語版が二重丸(`ICON_DOUBLE_RING`)、英語版が「✓ Synced, pupils too」。選んだカードは太い枠と左上の ✓、コマンドで固定中は灰色。カードの下に小さな[VRCFT(ETVR)](高さ 40、選んでいれば ✓ とオレンジの枠)と「削除予定」、ETVR の 2 行を「 ・ 」でつないで 1 行で。「VRChat に直接」を選んでいるときは、その小さなボタンの上に灰色の 1 行「目が動かないアバターは、VRCFT(LiveLink か FrameEyeOSC)にすると動きます」(`vrchatDirectHint`)。ETVR を選んでいるときだけ、カードとボタンのあいだにオレンジの案内「いまは VRCFT(ETVR)に送っています。次の版で ETVR はなくなります」「上の 3 つから、ほかの送り先を選び直してください」〔`outputDeprecated`〕。勝手にほかの送り先へは移さない)、目のカメラ(準備前は「使っていない」と[はじめる →](目のカメラタブへ。タブがあるときだけ)。準備後は「使う / 使わない」(`camera_lids`。0.7.6 までは目のカメラタブの「カメラで瞼を取る」)と短い状態: 左・右 OK / 左だけ / 右だけ / オフ / 目を覚えてる途中 / 待機中(`cameraBrief`))、言語(日本語 / English。右に[すべて既定に戻す])、SteamVR と一緒に起動(オン / オフ。右に[アプリを終了])。いちばん下の「押すとすぐ本体に反映され…」は入るときだけ(ETVR の案内が出ているときは省く) | | 送り方 | 送り先の PC(自動 / 今の相手で固定 / IP を入力。手で決めた host は 3 つ目に「手動 192.168.1.20」と出る)、ポート(− / +、既定に戻す。ヒントに送り先の種類の既定)。VRChat に直接のときだけ パラメーター名の頭(/FT / なし、送るアドレスの例)、EyeTrackingActive の型(Bool / Float / 送らない、`eye_tracking_active`)、Steam Link の名前も送る(オン / オフ、`steamlink_params`。Steam Link の OSC 向けのアバター用。送るアドレスの例に「頭はつけない」)、VRChat 標準の目も動かす(オン / オフ、`native_eyes`。VRCFT 用の値がないアバター用)。LiveLink / FrameEyeOSC / ETVR のときは「PC の VRCFT で準備すること」の手順(LiveLink と ETVR は 3 つ。FrameEyeOSC は 4 つ: PC のブラウザーで zip を落とす・「Module Registry」の[Install from .zip]・ほかの目のモジュールはオフ・UDP 11112。1 つ目の下にアクセント色の入手先 github.com/sasaken1102r/VRCFT-FrameEyeOSC/releases があり、押すと Frame の Chromium でそのページを開く〔`setup_tools::kModulePageUrl`〕。開けなかったら、そのすぐ下に赤の 1 行「ブラウザーを開けませんでした: 理由」〔目のカメラの準備の画面には出さない。次に開けたときと送り先が変わったときに消える。`--fake-module-link-error`〕)と注記(入りきらないとき〔LiveLink で目のカメラを使っているとき〕は注記を省く。FrameEyeOSC で目のカメラを使っているときは「瞳孔の大きさも、VRCFT がアバターに合った形で送ります。」を足す)。FrameEyeOSC には瞳孔の行が無い(VRCFT が送る)。目のカメラを使っていないときだけ点線の 1 行。目のカメラを使っているとき(`eyecam::inUse`: 準備が済んでいて `camera_lids` がオン)は、LiveLink なら「瞳孔は VRChat に直接送る」(`pupils_to_vrchat`)、VRChat に直接と LiveLink なら「瞳孔の受け取り方」(小数 / 1 / 2 / 3 / 4、`pupil_bits`。PupilDilation1・2・4・8 の bool も送る個数。LiveLink で `pupils_to_vrchat` がオフのときは灰色で押せない)。どちらも緑の「カメラ」バッジ付き。使っていないときはその場所に点線の 1 行「目のカメラを使うと、瞳孔の大きさも送れるよ」(押すと目のカメラタブ。`pupilsLineShown`) | -| 視線 | いちばん上の切り替えで 3 つの小タブ(詳細・まぶたタブと同じ形。初めては「動き」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `gaze_page` で覚えている)。「動き」: スムージング(オン / オフ)、なめらかさ(弱 / 中 / 強)、細かく変える(止まっている時・速い動き・変化の感度の 3 つを − / +)、見つめている時の遊び(角度も表示)。「動く範囲」: 視線の倍率(左・右、上・下の − 100% +。5〜200 %、5 % 刻み、`gaze_scale_left/right/up/down`。ヒント「目合わせのあとに掛けます」。左右・上下のあいだに鍵: 閉じている(アクセント色)あいだは片方を変えるともう片方も同じ値に、開く(灰色)と別々、閉じ直すと右 := 左・下 := 上(`gazeScaleStep` / `gazeScaleRelink`)。鍵は `panel.json` の `gaze_scale_link_x` / `_y` に覚え、起動時に値が違う組は開いて見せる(`gazeScaleLinkShown`)。どちらかがコマンドで固定中なら鍵は押せない)、左右の目を別々に動かす(Frame が片目だけで追っているあいだは、ヒントが「片目だけ追跡中は両目が同じ向き」になる)。「乱れ対策」: まばたき中は視線を止める(オン / オフとしきい値)、不確かな視線を使わない(オン / オフと上限)、一瞬の途切れを消す(オン / オフ)。`--gaze-page motion|reach|glitch` で小タブを選んで描き、`--fake-gaze-scale L,R,U,D` で倍率を入れる | +| 視線 | いちばん上の切り替えで 3 つの小タブ(詳細・まぶたタブと同じ形。初めては「動き」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `gaze_page` で覚えている)。「動き」: スムージング(オン / オフ)、なめらかさ(弱 / 中 / 強)、細かく変える(止まっている時・速い動き・変化の感度の 3 つを − / +)、見つめている時の遊び(角度も表示)。「動く範囲」: 視線の倍率(左・右、上・下の − 100% +。5〜200 %、5 % 刻み、`gaze_scale_left/right/up/down`。ヒント「目合わせのあとに掛けます」。左右・上下のあいだに鍵: 閉じている(アクセント色)あいだは片方を変えるともう片方も同じ値に、開く(灰色)と別々、閉じ直すと右 := 左・下 := 上(`gazeScaleStep` / `gazeScaleRelink`)。鍵は `panel.json` の `gaze_scale_link_x` / `_y` に覚え、起動時に値が違う組は開いて見せる(`gazeScaleLinkShown`)。どちらかがコマンドで固定中なら鍵は押せない)、左右の目を別々に動かす(Frame が片目だけで追っているあいだは、ヒントが「片目だけ追跡中は両目が同じ向き」になる)、その下に目ごとの視線(`own_eye_gaze` の − / +、0〜100 %、10 % 刻み。右に「0 % で安定、100 % で目ごとそのまま(ぶれる)」。「別々に動かす」がオフのときは薄くして押せなくし、v1 のときは薄くしたうえで右を「v2 だけで効くよ。今は v1 なので使ってない」と[詳細 › バージョンへ]に)。「乱れ対策」: まばたき中は視線を止める(オン / オフとしきい値)、不確かな視線を使わない(オン / オフと上限)、一瞬の途切れを消す(オン / オフ)。`--gaze-page motion|reach|glitch` で小タブを選んで描き、`--fake-gaze-scale L,R,U,D` で倍率を入れる | | 目を合わせる | [目を合わせる](ヒント「正面・上の幅・まぶた・約 20 秒」。合わせたあとは[もう一度合わせる])と小さい[真ん中だけ合わせ直す]、「被ったとき」の行(何もしない / 真ん中だけ、`auto_recenter`。既定は真ん中だけ。0.7.6 までの `"tilt"` は `"center"` として読む)、今どうなっているか(始め方・結果[元に戻す]・止めた / 失敗の理由。被ったときの合わせ直しをダッシュボードが止めて、もう一度やるときは「ダッシュボードを閉じたら、もう一度合わせ直します」も)。目を合わせている間はタブ全体が「点を見てください」と何をするか(動かない文字)と大きな[やめる]だけになり、左の列とタブの並びは暗くなる(押せるのはタブと[やめる]だけ。ほかのタブを押すと止まる)、[♪ 音を鳴らす: オン / オフ]、「細かく直す」(開閉。[視線 / まぶた]で切り替え。視線: 正面の位置・動く幅(左右・上・下)、真下で左右を止める角度。まぶた: 目ごとの読んだ値 閉じ / 上 / 正面 / 下 の − / +。閉じているあいだは、その下に「合わせ方」の箱: 被るたびは真ん中の点を 1 つ見るだけ(自動)、[目を合わせる]は最初と気になったときだけ、傾きは使わなくなったこと) | | まぶた | 見た目の調整を全部ここに(案E)。いちばん上の切り替えで 4 つの小タブ(詳細タブと同じ形。初めては「見た目」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `lids_page` で覚えている)。「見た目」: 「いまのまぶた」の帯(目のカメラ(左・右)/ 目のカメラ(左)+ Valve(右)/ 目のカメラ(待機中)/ Valve の値。準備が済んでいれば右に「オン・オフは「基本」タブで」)、「見開き」の行(する / しない で `widen` を `SetBool`。カメラを使っていてもいなくてもいつも出す。ヒント「閉じ・細めはそのまま」。「しない」のあいだは下の「見開きの出やすさ」を灰色にして押せなくし、その下の 1 行を「「見開き」が「しない」なので、いまは使わないよ」に。目のカメラタブのいちばん下の文も「カメラで両目を見て、まぶたを送ってるよ(見開きはしない)」に、診断の「まぶた」の行には「· 見開きしない」を足す。`--fake-no-widen` で描ける)、目のカメラを使っているときだけ「見開きの出やすさ」のスライダー 1 本(緑の「カメラ」バッジ。鈍い ↔ 敏感。両目がカメラなら eyecam-rec の `widen_sensitivity` を `set widen_sensitivity` で送る。片目だけカメラなら同じく感度を送り、Valve の目には近い `lid_widen` の段階も一緒に書く。Valve の値なら `lid_widen` の 4 段階(しない / 控えめ / ふつう / 出やすい)で、押した所に近い止まり位置へ。目を合わせていない目があると下に「目を合わせていない目は、「細かく直す」の目盛りで決まるよ」。いまカメラの目が無く、ふつうに開いた目が 1.0 と読まれる SteamOS(`openness_saturated`)では、スライダーの代わりに 1 行: ヒント「この SteamOS では効かないよ」、「Valve の値だけだと見開きが出ないよ」と[目のカメラへ →](目のカメラタブがあるときだけ))、目のカメラを使っていて eyecam-rec の status.json に `lid_veto.on` があるときだけ「半目の読み直し(AI)」(する / しない。緑の「カメラ」バッジ。eyecam-rec に `set lid_veto on|off` を送り、選ばれているほうは status.json の `lid_veto.on` のまま〔panel.json には持たない〕。下に 3 行までの説明。`--fake-eyecam …:veto=on|off` で描ける)、まぶたのなめらかさ(弱 / 中 / 強。中 = 本体の既定値、どれにも合わなければカスタム)、左右をそろえる強さ(`lid_sync` のスライダー、0.05 刻み)。目のカメラを使っていないときは、いちばん下に点線の 1 行「目のカメラを使うと「見開きの出やすさ」が増えるよ」(押すと目のカメラタブ)。なので、カメラが無いと `lid_widen` はパネルから変えられない。「まばたき・細め」: まばたきを届ける(保持 ms の − / +)、まばたきを両目でそろえる(`blink_sync_below` の − / +、0 でオフ)、細めたときのまぶたの下限(`camera_lid_floor` の − / +、0.05 刻み、0 でオフ。目のカメラを使っているときだけ出す。緑の「カメラ」バッジ。カメラの目がまだないときは「目のカメラが目を見てるときに効くよ」と添える。使っていないときは点線の 1 行「目のカメラを使うと「細めたときのまぶたの下限」も使えるよ」)。それぞれ右に説明。「細かく直す」: いちばん上に目盛りの決まり方の 1 行、自動キャリブレーション(目を合わせていない目で、カメラでないとき)、左右の倍率、生の値の棒と 4 つの目盛り(③④は目を合わせた目とカメラのときは灰色)、ほぼ開いてたら全開にする(⑤。生の値の棒にも ⑤ の青い破線を出す。目を合わせた目は倍率をかけた位置、カメラが読んでいる目は薄く、0.75 のときは出さない。行の数字と − / + の縁も同じ青。`lid_open_snap` の − / +、送るまぶたの値で 0〜0.75 の 0.01 刻み、0.75 でオフ。いつも出す。右に説明。開いた値に対する割合だったころの 0.75 より大きい値は、起動時に一度だけ置き換える(`migrateLidOpenSnap`))、なめらかさの 2 つの値(止まっている時・速い動き。ページに収まるよう、それぞれの左に名前を置いた 1 行)。目のカメラタブの[まぶたへ →]は「見た目」を開く。「眉」: 「見開きで眉も上げる」(する / しない で `brow_from_widen` を `SetBool`。ヒント「カメラは眉を見ていないよ」。カメラを使っていてもいなくても、「見開き」が「しない」でもいつも出す。下に 3 行までの説明〔`\n` の所で改行〕)と、「する」のあいだだけ「眉の上がり方」(`brow_strength` のスライダー、弱い ↔ 強い、0.05 刻み。ヒント「アバターに合わせて」)。`--fake-brows` で「する」を描ける。`--lids-page look|blinks|fine|brows` で小タブを選んで描く(`--lid-marks` は fine と同じ) | | 目のカメラ | eyecam-rec が動いているあいだだけ出る(「詳細」の前。出る条件は下の「目の撮影」と同じ)。準備を始める前(準備が済んだことがなく、まだ ① か ② の段で、このパネルを起動してから[はじめる]を押していないとき。`eyecam::introShown`)は、「目のカメラでできること」(見開き・瞳孔の大きさ・Valve がまぶたを低く読んだときにカメラで確かめて直す)、1 回だけの準備と目の映像は Frame の外に出さないことの箱、[はじめる(約 2 分)]、「使わなくても、目の送信はふつうに動きます」。押したあと(と準備が進んでいるとき)は準備のチェックリスト(いまの段階は、満たしていない最初のもの。毎回 status.json とパスワードの確認だけで決める): ① パスワードを決める(SteamOS にパスワードが無いと分かっているときだけ。`steamos-passwd --has-password` を裏で数秒おきに確かめる。読むだけ。[Konsole で passwd を開く])② 道具を入れる([Konsole で開く]で `sudo $HOME/.local/lib/eyecam/install_grab.sh` が入力済みの Konsole を開くだけ。Enter とパスワードは本人で、パネルは sudo も passwd も実行しない。`auto_grab` が ok / trying / waiting_tracker / failed: … か、バッファを持っていれば入っている扱い。`auto_grab` が `too_old`(eyecam の安全のための下限より古く、起動されない)なら入っていない扱い。`grab_outdated` が true(更新で新しい道具が届いた)か too_old なら、見出しを「道具を入れ直す」、その下に「道具が新しくなったよ、入れ直してね」)③ 目の動きを覚える(カメラの映像は「届いてるよ」、`cam_fps` が毎秒 60 枚より少なければアクセント色で「届いてるよ(毎秒 15 枚・少なめ)」。目ごとに「見えてるよ」(status.json の `pupil_l` / `pupil_r` で瞳が 50 % 以上)・「映像はあるけど瞳が見つからない」(`locked` だけど 20 % 未満、赤)・「瞳がときどきしか見えない」(そのあいだ)・「見えてないよ」(`locked` が false)。`pupil_l` / `pupil_r` の無い古い eyecam-rec では今までどおり `locked` だけで決める。目の映像が届いているとき(`locked`)は[覚えはじめる]で `calib wear` を押せて、瞳が見つからない目があるときはその下に赤字で「HMD をかぶり直して」と出す。18 秒の間、ステップのチップ・残り秒・[やめる]をカードの中に出す。失敗したら eyecam-rec のメッセージ(数も入っている)の上に、メッセージが名前を出した目(「うまくいかなかった目」)を出す。見開きだけ取れなかったら[このまま進む][もう一度(18秒)]。片目だけ失敗して通ったとき(`calib_failed_eye` が L / R)は「左目は前の値を使うよ」(初めてなら「仮の値」)と目ごとの結果、メッセージ、[使いはじめる][もう一度(18秒)])④ 完了(「使いはじめる」)。セットアップ済みでも道具が無くなれば ② に戻り、直れば校正し直さずにふだんの画面へ。道具が古いだけなら ② には戻さず、ふだんの画面のいちばん上にお知らせのカードと[Konsole で開く](`grab_outdated`: 「道具が新しくなったよ、入れ直してね(今の道具のままでも動くよ)」、`too_old`: 赤いカードで「安全のため、道具を入れ直してね。入れ直すまでカメラは止まってて、まぶたは Valve の値だけで送ってるよ」)、左の列にも同じお知らせ(押すとこのタブ)。eyecam-rec が言わなくなれば自然に消える(`eyecam::toolState` / `toolNotice`。`--fake-eyecam …:outdated`、`waiting:auto=too_old:setup:outdated` で描く)。左の列には、準備が済むまで「次にやること・目のカメラ」、済んだ直後に緑の「目のカメラ:準備できたよ」。済んだあとのふだんの画面: いまの状態(両目・片目・Valve の値とその理由。ヒントは「オン・オフは「基本」タブで」。カメラのスイッチ `camera_lids` は「基本」タブに移した。オフのあいだの校正のボタンは「「基本」タブで目のカメラを「使う」にしてから」)、その下の行の右に[普段の目を覚え直す](英語は Relearn my eyes。左に「約 30 秒。そのあいだも今の値で動くよ」。eyecam-rec に `relearn` を送り、覚え始めがやり直しになって、いまの状態の右のピルが「普段の目を覚えてるよ(あと N 秒)」に戻る。覚えているあいだも、もう覚えていた目は前の値でまぶた・見開き・細めを送り続ける(`calib_state` の 4 はそのまま)。押せるのは `eyecam::relearnAllowed`: eyecam-rec の状態が新しく、idle・live・目の映像あり〔`locked`〕・`baseline` を書く eyecam-rec、`camera_lids` がオン、カメラがまぶたを動かしているか覚えている途中〔`relearnUse`〕、ほかのコマンドの返事待ちでない)、違和感があるときの[目のカメラの校正(18秒)]、細めも送るときの[ユーザー校正(最初に 1 回)](どちらも ③ と同じカードで進み、終わると結果のカード)、こんなときは。見開きの出やすさは「まぶた」タブ。`--fake-eyecam` と `--fake-password`、`--fake-camera` で描く | diff --git a/panel/src/config.cpp b/panel/src/config.cpp index 16eb51b..92bae42 100644 --- a/panel/src/config.cpp +++ b/panel/src/config.cpp @@ -150,6 +150,7 @@ const std::vector& settingSpecs() { {key::kGazeDeadzone, SettingType::Number, 0.005, "", 0.0, 0.2, 0.005, 3}, {key::kGazeHoldBelow, SettingType::Number, 0.5, "", 0.05, 1.0, 0.05, 2}, {key::kIndependentEyes, SettingType::Bool, 0, "", 0, 1, 1, 0}, + {key::kOwnEyeGaze, SettingType::Number, 0.0, "", 0.0, 1.0, 0.1, 2}, {key::kLidMinCutoff, SettingType::Number, 6.0, "", 0.5, 30.0, 0.5, 1}, {key::kLidBeta, SettingType::Number, 5.0, "", 0.0, 30.0, 0.5, 1}, {key::kLidClosed, SettingType::Number, 0.30, "", 0.0, 1.5, 0.01, 2}, diff --git a/panel/src/config.h b/panel/src/config.h index 5bd223f..a130b52 100644 --- a/panel/src/config.h +++ b/panel/src/config.h @@ -77,6 +77,8 @@ constexpr const char* kGazeDCutoff = "gaze_d_cutoff"; constexpr const char* kGazeDeadzone = "gaze_deadzone"; constexpr const char* kGazeHoldBelow = "gaze_hold_below"; constexpr const char* kIndependentEyes = "independent_eyes"; +/** With independent_eyes in v2: how much of each eye's own sideways gaze is sent, 0 (the default) to 1. */ +constexpr const char* kOwnEyeGaze = "own_eye_gaze"; constexpr const char* kLidMinCutoff = "lid_min_cutoff"; constexpr const char* kLidBeta = "lid_beta"; constexpr const char* kLidClosed = "lid_closed"; diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index 27cee37..ba1cd3b 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -221,6 +221,8 @@ UiText makeJapanese() { t.rowIndependent = "左右の目を別々に動かす"; t.hintIndependent = "少し寄り目にして、上下は目ごと"; t.hintIndependentOneEye = "片目だけ追跡中は両目が同じ向き"; + t.rowOwnEyeGaze = "目ごとの視線"; + t.hintOwnEyeGaze = "0 % で安定、100 % で目ごとそのまま(ぶれる)"; t.rowQuality = "不確かな視線を使わない"; t.hintQuality = "この値より不確かな目は無視"; t.rowDespike = "一瞬の途切れを消す"; @@ -1185,6 +1187,8 @@ UiText makeEnglish() { t.rowIndependent = "Move eyes separately"; t.hintIndependent = "Turned in a little, up/down per eye"; t.hintIndependentOneEye = "Both follow the one tracked eye"; + t.rowOwnEyeGaze = "Own gaze per eye"; + t.hintOwnEyeGaze = "0 % steady, 100 % each eye as tracked (wobbly)"; t.rowQuality = "Skip unreliable gaze"; t.hintQuality = "Ignores an eye less sure than this"; t.rowDespike = "Remove glitches"; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index 0532b12..2be2f99 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -226,6 +226,8 @@ struct UiText { const char* rowIndependent; const char* hintIndependent; const char* hintIndependentOneEye; ///< instead, while the Frame tracks one eye alone (both eyes get its gaze) + const char* rowOwnEyeGaze; ///< under it: how much of each eye's own sideways gaze (own_eye_gaze)... + const char* hintOwnEyeGaze; ///< ...and what 0 % and 100 % do, beside the stepper const char* rowQuality; const char* hintQuality; const char* rowDespike; diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index c62d1e1..7872595 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -2661,6 +2661,31 @@ void EyePanel::drawGazeReach(const Pen& pen, const UiText& t, const PanelModel& {t.off, {PanelAction::SetBool, key::kIndependentEyes, 0}}}, v.flag(key::kIndependentEyes) ? 0 : 1, 20, locked); } + y += kRowH + kRowGap; + // How much of each eye's own sideways gaze (own_eye_gaze): only while the eyes move separately in v2, faded + // otherwise; in v1 with why and the way to the switch beside it, else what the ends do + { + const bool locked = v.locked(key::kOwnEyeGaze); + const bool v1 = eyesV1(v); + const bool usable = !v1 && v.flag(key::kIndependentEyes); + const double stepperW = 196; + const double value = v.number(key::kOwnEyeGaze); + const double nx = kControlX + stepperW + 16; + if (!usable) cairo_push_group(pen.cr); + drawRowLabel(pen, t, y, kRowH, t.rowOwnEyeGaze, "", locked); + drawStepper(pen, kControlX, y + cy, stepperW, kControlH, key::kOwnEyeGaze, value, formatPercent(value), usable, + locked); + if (!v1) { + const std::vector lines = wrapText(pen, t.hintOwnEyeGaze, 13, false, kInnerRight - nx, 3); + double baseline = y + kRowH / 2 + 4 - (static_cast(lines.size()) - 1) * 8.5; + for (const std::string& line : lines) { + pen.text(nx, baseline, line, 13, kTextMuted); + baseline += 17; + } + } + if (!usable) fadeGroup(pen); + if (v1) drawV2OnlyNote(pen, t, t.floorV2Only, nx, y, kRowH); + } } void EyePanel::drawGazeGlitch(const Pen& pen, const UiText& t, const SettingsView& v, double top) { diff --git a/panel/tests/model_test.cpp b/panel/tests/model_test.cpp index de18141..6d2f6ac 100644 --- a/panel/tests/model_test.cpp +++ b/panel/tests/model_test.cpp @@ -821,6 +821,36 @@ void testBrowSettings() { CHECK(SettingsView(m).locked(key::kBrowStrength) && std::fabs(SettingsView(m).number(key::kBrowStrength) - 0.3) < 1e-12); } +/** own_eye_gaze (Gaze > Reach, "Own gaze per eye"): 0 by default, 0..1 in steps of 0.1, shown as a percentage, read + * from config.json and written back as frameeyeosc reads it, and frameeyeosc's own value while its command line + * (--own-eye-gaze) locks it. */ +void testOwnEyeGazeSetting() { + const SettingSpec* spec = findSetting(key::kOwnEyeGaze); + CHECK(spec != nullptr && spec->type == SettingType::Number && spec->defaultNumber == 0.0); + CHECK(spec != nullptr && spec->min == 0.0 && spec->max == 1.0 && spec->step == 0.1); + if (spec != nullptr) { + CHECK(stepValue(*spec, 0.0, 1, -1e9, 1e9) == 0.1 && stepValue(*spec, 0.2, 1, -1e9, 1e9) == 0.3); + CHECK(stepValue(*spec, 1.0, 1, -1e9, 1e9) == 1.0 && stepValue(*spec, 0.0, -1, -1e9, 1e9) == 0.0); + CHECK(stepValue(*spec, 0.37, -1, -1e9, 1e9) == 0.3 && snapValue(*spec, 1.4) == 1.0); + } + CHECK(formatPercent(0.0) == "0%" && formatPercent(0.3) == "30%" && formatPercent(1.0) == "100%"); + PanelModel m; + m.config.root = JsonValue(); + m.config.root.type = JsonValue::Type::Object; + CHECK(SettingsView(m).number(key::kOwnEyeGaze) == 0.0); + m.config.root.set(key::kOwnEyeGaze, JsonValue::makeNumber(0.6)); + const std::string text = writeJson(m.config.root); + CHECK(text.find("\"own_eye_gaze\": 0.6") != std::string::npos); + JsonValue back; + std::string error; + CHECK(parseJson(text, back, error)); + const JsonValue* value = back.get(key::kOwnEyeGaze); + CHECK(value != nullptr && value->isNumber() && std::fabs(value->number - 0.6) < 1e-12); + m.status = parseStatus("{\"pid\": 1, \"locked\": [\"own_eye_gaze\"], \"effective\": {\"own_eye_gaze\": 1.0}}", 0, + false); + CHECK(SettingsView(m).locked(key::kOwnEyeGaze) && SettingsView(m).number(key::kOwnEyeGaze) == 1.0); +} + /** gaze_debug_rings (the rings by the debug gaze dots, Advanced > Debug tools): the panel's own, like the dots' * distance; on by default and read from config.json, off only when the file says false. */ void testGazeDebugRings() { @@ -1388,6 +1418,7 @@ int main() { testCameraLids(); testWiden(); testBrowSettings(); + testOwnEyeGazeSetting(); testGazeDebugRings(); testCameraStatus(); testLidsFromCameras(); diff --git a/panel/tests/panel_test.cpp b/panel/tests/panel_test.cpp index e483daf..cf08ab6 100644 --- a/panel/tests/panel_test.cpp +++ b/panel/tests/panel_test.cpp @@ -1064,6 +1064,8 @@ void testGazePages(const FontSet& fonts) { for (const EyePanel::HitArea& area : locks) CHECK(area.w >= 44 && area.h >= 44); if (reach && locked && locks.size() == 1) CHECK(locks[0].hit.arg == 1); CHECK(hits(panel, PanelAction::SetBool, key::kIndependentEyes).size() == (reach && !locked ? 2u : 0u)); + // ...and under it each eye's own gaze, nothing to press while they don't (testOwnEyeGazeRow) + CHECK(hits(panel, PanelAction::Step, key::kOwnEyeGaze).empty()); // Glitch guard: the hold, skipping unreliable gaze, the despike CHECK(hits(panel, PanelAction::NumberOff, key::kGazeHoldBelow).size() == (glitch && !locked ? 1u : 0u)); @@ -1083,6 +1085,48 @@ void testGazePages(const FontSet& fonts) { } } +/** own_eye_gaze (Gaze > Reach, "Own gaze per eye", under "Move eyes separately"): − / + in steps of 10 % only while + * the eyes move separately in v2 and it isn't locked; faded otherwise, in v1 with the button to that switch. Inside + * the card in both languages. */ +void testOwnEyeGazeRow(const FontSet& fonts) { + for (const Language language : {Language::Ja, Language::En}) { + for (const bool independent : {false, true}) { + for (const bool v1 : {false, true}) { + for (const bool locked : {false, true}) { + for (const double value : {0.0, 0.5, 1.0}) { + PanelModel m = modelWith(Lids::Valve, false); + m.language = language; + m.config.root.set(key::kIndependentEyes, JsonValue::makeBool(independent)); + m.config.root.set(key::kOwnEyeGaze, JsonValue::makeNumber(value)); + if (v1) m.config.root.set(key::kEyeBehavior, JsonValue::makeNumber(1, true)); + if (locked) m.status.locked.push_back(key::kOwnEyeGaze); + EyePanel panel(fonts); + panel.setTab(PanelTab::Gaze); + panel.setGazePage(GazePage::Reach); + panel.render(m); + const std::vector steps = hits(panel, PanelAction::Step, key::kOwnEyeGaze); + const bool usable = independent && !v1 && !locked; + // At 0 % only +, at 100 % only − + CHECK(steps.size() == (!usable ? 0u : (value > 0 && value < 1 ? 2u : 1u))); + const std::vector separate = + hits(panel, PanelAction::SetBool, key::kIndependentEyes); + CHECK(separate.size() == 2); + for (const EyePanel::HitArea& area : steps) { + CHECK(area.w >= 44 && area.h >= 44 && area.x >= 400 && area.x + area.w <= 1176); + CHECK(area.y > separate[0].y + separate[0].h && area.y + area.h <= 664); + } + int toVersion = 0; + for (const EyePanel::HitArea& area : hits(panel, PanelAction::AdvancedPage)) { + if (area.x >= 400 && area.hit.arg == static_cast(AdvPage::Version)) ++toVersion; + } + CHECK(toVersion == (v1 ? 1 : 0)); + } + } + } + } + } +} + void testGazeScaleLocks(const FontSet& fonts) { PanelModel m = modelWith(Lids::Valve, false); EyePanel panel(fonts); @@ -2935,6 +2979,7 @@ int main() { testEyeBehavior(fonts); testLidsRemembered(fonts); testGazePages(fonts); + testOwnEyeGazeRow(fonts); testGazeScaleLocks(fonts); testSliderMoved(fonts); testRelearnButton(fonts); diff --git a/src/config.rs b/src/config.rs index 1fa751b..5cc86b2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -165,6 +165,10 @@ pub struct Settings { pub gaze_deadzone: f32, pub gaze_hold_below: f32, pub independent_eyes: bool, + /// With independent_eyes (eye_behavior 2), how much of each eye's own sideways gaze is used, 0..1: 0 the combined + /// gaze turned in by VERGENCE_DEG (and the cross-eye gate), 1 each eye's own as Valve reads it (see main.rs, + /// own_eye_gaze). + pub own_eye_gaze: f32, pub lid_min_cutoff: f32, pub lid_beta: f32, pub lid_closed: f32, @@ -265,6 +269,7 @@ impl Default for Settings { gaze_deadzone: 0.005, gaze_hold_below: 0.5, independent_eyes: false, + own_eye_gaze: 0.0, lid_min_cutoff: 6.0, lid_beta: 5.0, lid_closed: 0.30, @@ -370,6 +375,7 @@ impl Settings { self.gaze_d_cutoff, self.gaze_deadzone, self.gaze_hold_below, + self.own_eye_gaze, self.lid_min_cutoff, self.lid_beta, self.lid_closed, @@ -448,6 +454,9 @@ impl Settings { if !(0.0..=1.0).contains(&self.brow_strength) { return Err("brow_strength must be between 0 and 1".into()); } + if !(0.0..=1.0).contains(&self.own_eye_gaze) { + return Err("own_eye_gaze must be between 0 and 1".into()); + } if self.lid_sync < 0.0 { return Err("lid_sync must be non-negative".into()); } @@ -648,6 +657,7 @@ pub fn apply_args(settings: &mut Settings, args: &Args, given: &HashSet) gaze_deadzone, gaze_hold_below, independent_eyes, + own_eye_gaze, lid_min_cutoff, lid_beta, lid_closed, @@ -944,6 +954,22 @@ mod tests { assert!(!settings.widen && settings.brow_from_widen); } + #[test] + fn own_eye_gaze_is_0_until_set() { + let (settings, locked) = merged("{}", &[]).unwrap(); + assert_eq!((settings.own_eye_gaze, locked.len()), (0.0, 0)); + assert_eq!(merged(r#"{"own_eye_gaze": 0.7}"#, &[]).unwrap().0.own_eye_gaze, 0.7); + for bad in [r#"{"own_eye_gaze": 1.1}"#, r#"{"own_eye_gaze": -0.1}"#, r#"{"own_eye_gaze": true}"#] { + assert!(merged(bad, &[]).is_err(), "{bad}"); + } + let (settings, locked) = merged(r#"{"own_eye_gaze": 0.2}"#, &["--own-eye-gaze", "1"]).unwrap(); + assert_eq!((settings.own_eye_gaze, locked), (1.0, vec!["own_eye_gaze"])); + // As the status file has it, and back + let text = serde_json::to_string(&Settings { own_eye_gaze: 0.5, ..Settings::default() }).unwrap(); + assert!(text.contains(r#""own_eye_gaze":0.5"#)); + assert_eq!(merged(&text, &[]).unwrap().0.own_eye_gaze, 0.5); + } + #[test] fn eye_tracking_active_is_bool_float_or_off() { for (text, kind) in [("bool", ActiveType::Bool), ("float", ActiveType::Float), ("off", ActiveType::Off)] { diff --git a/src/main.rs b/src/main.rs index 1f4b6ff..6690575 100644 --- a/src/main.rs +++ b/src/main.rs @@ -644,6 +644,11 @@ struct Args { /// --eye-behavior 1, each eye's own gaze as the Frame reads it #[arg(long)] independent_eyes: bool, + /// With --independent-eyes, how much of each eye's own sideways gaze goes out, 0..1: 0 the eyes turned in as above, + /// 1 each eye's sideways gaze as the Frame reads it (unsteady); between, that far from one to the other. Eyelids, + /// smoothing and holding stay as they are. Not with --eye-behavior 1, which sends each eye's own gaze anyway + #[arg(long, default_value_t = 0.0)] + own_eye_gaze: f32, /// How the eyes move: 2 (the default) as now; 1 as up to 0.7.5 (each eye's own gaze with --independent-eyes, no /// cross-eye gate, and none of the eyelid rules added since: the eye camera only widens, nothing eases nearly open /// eyelids up, no --camera-lid-floor or --lid-open-snap) @@ -2452,6 +2457,25 @@ fn choose_gaze(angles: [f32; 6], reliable: [bool; 2], independent: bool, v2: boo } } +/// With --independent-eyes (`gaze` from choose_gaze with --eye-behavior 2), each eye's sideways gaze moved `own` of the +/// way (--own-eye-gaze) to its own as Valve reads it in `angles`; an eye that isn't `usable` takes the other's, as in +/// choose_gaze. Up / down and the combined gaze are left as they are, and 0 leaves everything exactly as it is. +fn own_eye_gaze(gaze: [f32; 6], angles: [f32; 6], usable: [bool; 2], own: f32) -> [f32; 6] { + if own <= 0.0 { + return gaze; + } + let own_x = match usable { + [true, false] => [angles[0]; 2], + [false, true] => [angles[2]; 2], + _ => [angles[0], angles[2]], + }; + let mut gaze = gaze; + for eye in 0..2 { + gaze[2 * eye] = (1.0 - own) * gaze[2 * eye] + own * own_x[eye]; + } + gaze +} + /// Map Frame eye openness onto VRCFT EyeLid, where 0 is closed, 0.75 relaxed open and 1 widened. /// A held-closed eye reads ~0.2 on the Frame rather than 0, hence the closed threshold. /// A relaxed eye wanders between ~0.75 and ~0.9, so widening only starts past a deadzone. @@ -3563,6 +3587,11 @@ fn process( let (camera_lids, close_held) = smoother.gate_closed(settings, data, camera_lids); let corrected = correct_gaze(raw_gaze, settings); let unscaled_gaze = choose_gaze(corrected, [true; 2], settings.independent_eyes, v2); + let unscaled_gaze = if settings.independent_eyes && v2 { + own_eye_gaze(unscaled_gaze, corrected, [true; 2], settings.own_eye_gaze) + } else { + unscaled_gaze + }; let gaze = scale_gaze(unscaled_gaze, settings); let zoned = camera_lids.map(|camera| v2 && camera.is_none()); let fits = fits_at(settings, corrected[5], zoned); @@ -3640,6 +3669,10 @@ fn process( let angles: [f32; 6] = std::array::from_fn(|i| readings[i]); let openness = [readings[6], readings[7]]; let mut gaze = choose_gaze(angles, usable_gaze, settings.independent_eyes, v2); + // Here, so the filters see each eye's own share too (--own-eye-gaze) + if settings.independent_eyes && v2 { + gaze = own_eye_gaze(gaze, angles, usable_gaze, settings.own_eye_gaze); + } // One unreliable eye takes the other's gaze (choose_gaze), so only both being unreliable holds it; with // --eye-behavior 1 each eye's own gaze is held while it is unreliable, but a doubted eye looks where the other // one does (its own gaze is no better than its openness) @@ -3713,7 +3746,8 @@ fn process( lids = smoother.sustain_widen(data.sample_time, lids); smoother.filter(dt, &mut gaze, &mut lids, hold); // Intentional cross-eye: Valve's own left - right, split evenly around the combined gaze, after the gaze filter - // (which would only slow it down) + // (which would only slow it down). Only on the share that isn't each eye's own (--own-eye-gaze), which has + // Valve's left - right already: at 1 nothing is added let open_line = if settings.gaze_hold_below > 0.0 { settings.gaze_hold_below } else { CROSS_EYE_OPEN }; let usable = reliable == [true; 2] && data.openness.iter().all(|openness| *openness >= open_line); let wide = data.openness.iter().all(|openness| *openness >= CROSS_EYE_ENGAGE_OPEN); @@ -3726,10 +3760,10 @@ fn process( wide, ); if settings.independent_eyes && v2 { - let extra = (vergence - VERGENCE_DEG) / 2.0 / 45.0; + let extra = (vergence - VERGENCE_DEG) / 2.0 / 45.0 * (1.0 - settings.own_eye_gaze); gaze[0] = (gaze[0] + extra).clamp(-1.0, 1.0); gaze[2] = (gaze[2] - extra).clamp(-1.0, 1.0); - cross_eye = smoother.cross_eye.engaged; + cross_eye = smoother.cross_eye.engaged && settings.own_eye_gaze < 1.0; } // The user's own scale last, on the combined direction (see scale_gaze) let unscaled_gaze = gaze; @@ -5408,6 +5442,132 @@ mod tests { } } + /// Each eye's own sideways gaze `[left, right]` and up / down `[left, right]`, the combined gaze along `combined`, + /// with both eyes open. + fn own_eyes(index: usize, x: [f32; 2], y: [f32; 2], combined: [f32; 2]) -> EyeData { + let mut data = reading(index, combined, [0.9; 2]); + data.gaze = [[x[0], y[0], -1.0], [x[1], y[1], -1.0]]; + data + } + + fn own_share(own_eye_gaze: f32) -> Settings { + Settings { own_eye_gaze, ..independent() } + } + + #[test] + fn own_eye_gaze_0_sends_what_independent_eyes_did() { + let bits = |sent: &[Sample]| -> Vec<([u32; 6], [u32; 2], bool, bool)> { + sent.iter() + .map(|sample| { + let Sample { gaze, lids, cross_eye, gaze_held, .. } = *sample; + (gaze.map(f32::to_bits), lids.map(f32::to_bits), cross_eye, gaze_held) + }) + .collect() + }; + // A wobble in each eye's own x, a blink, and a cross-eye let through: bit for bit the same as without it + let mut readings: Vec = (0..200) + .map(|i| { + let wobble = if i % 20 < 10 { 0.2 } else { -0.1 }; + let openness = if (80..90).contains(&i) { [0.1; 2] } else { [0.9; 2] }; + let mut data = own_eyes(i, [0.1 + wobble, 0.1 - wobble], [-0.05, 0.02], [0.1, -0.02]); + data.openness = openness; + data + }) + .collect(); + readings.extend((200..650).map(|i| crossed(i, if i < 300 { 0.0 } else { 30.0 }, [0.9; 2]))); + for base in [independent(), Settings { raw: true, ..independent() }] { + let before = run(&base, &readings); + assert!(base.raw || before.iter().any(|sample| sample.cross_eye)); + assert_eq!(bits(&run(&Settings { own_eye_gaze: 0.0, ..base.clone() }, &readings)), bits(&before)); + } + // The function itself: untouched at 0 + let gaze = [0.1, -0.2, 0.3, -0.4, 0.2, -0.3]; + assert_eq!(own_eye_gaze(gaze, [0.9; 6], [true; 2], 0.0).map(f32::to_bits), gaze.map(f32::to_bits)); + } + + #[test] + fn own_eye_gaze_1_sends_each_eyes_own_sideways_gaze() { + // The eyes 9° apart (below the cross-eye gate), each with its own up / down + let x = [0.25f32, 0.09]; + let y = [-0.06f32, 0.04]; + let readings: Vec = (0..120).map(|i| own_eyes(i, x, y, [0.17, -0.01])).collect(); + let own = [gaze_angles([x[0], y[0], -1.0]), gaze_angles([x[1], y[1], -1.0])]; + let shared = run(&independent(), &readings); + let full = run(&own_share(1.0), &readings); + let half = run(&own_share(0.5), &readings); + let last = |sent: &[Sample]| sent.last().unwrap().gaze; + for (eye, [own_x, _]) in own.into_iter().enumerate() { + assert!((last(&full)[2 * eye] - own_x).abs() < 1e-4, "{:?}", last(&full)); + // Halfway between the two + let between = (last(&shared)[2 * eye] + last(&full)[2 * eye]) / 2.0; + assert!((last(&half)[2 * eye] - between).abs() < 1e-4, "{:?}", last(&half)); + } + assert!((sent_vergence(&full[119]) - (own[0][0] - own[1][0]) * 45.0).abs() < 1e-2); + // Up / down (each eye's own already) and the combined gaze stay as they were, bit for bit + for (shared, full) in shared.iter().zip(&full) { + for i in [1, 3, 4, 5] { + assert_eq!(shared.gaze[i].to_bits(), full.gaze[i].to_bits(), "{i}"); + } + } + // In --raw, as read + let raw = run(&Settings { raw: true, ..own_share(1.0) }, &readings); + assert!(raw.iter().all(|sample| sample.gaze[0] == own[0][0] && sample.gaze[2] == own[1][0])); + } + + #[test] + fn own_eye_gaze_1_does_not_add_the_cross_eye_gate_on_top() { + // 1 s straight ahead, then 4 s crossed at 30°: Valve's 30° as it is, not the gate's on top of it + let readings: Vec = (0..450).map(|i| crossed(i, if i < 90 { 0.0 } else { 30.0 }, [0.9; 2])).collect(); + let full = run(&own_share(1.0), &readings); + assert!(!any_cross_eye(&full)); + assert!(full[150..].iter().all(|sample| (sent_vergence(sample) - 30.0).abs() < 0.05)); + // Halfway, the gate's half and Valve's half add up to the same 30° once it is let through + let half = run(&own_share(0.5), &readings); + assert!(any_cross_eye(&half)); + assert!(half[200..].iter().all(|sample| (sent_vergence(sample) - 30.0).abs() < 0.2)); + } + + #[test] + fn own_eye_gaze_does_nothing_without_independent_eyes_or_in_v1() { + let readings: Vec = (0..150) + .map(|i| { + let wobble = if i % 20 < 10 { 0.2 } else { -0.1 }; + own_eyes(i, [0.1 + wobble, 0.1 - wobble], [-0.05, 0.02], [0.1, -0.02]) + }) + .chain((150..450).map(|i| crossed(i, 30.0, [0.9; 2]))) + .collect(); + let gaze = |sent: Vec| -> Vec<[u32; 6]> { sent.iter().map(|it| it.gaze.map(f32::to_bits)).collect() }; + let v1 = Settings { eye_behavior: 1, ..settings() }; + for base in [settings(), Settings { independent_eyes: true, ..v1.clone() }, v1] { + for raw in [false, true] { + let base = Settings { raw, ..base.clone() }; + let own = Settings { own_eye_gaze: 1.0, ..base.clone() }; + assert_eq!(gaze(run(&own, &readings)), gaze(run(&base, &readings))); + } + } + } + + #[test] + fn own_eye_gaze_1_still_moves_a_doubted_or_unreliable_eye_with_the_other() { + // The left eye read shut for long (#32), its own gaze off to the left: the right eye's moves both + let readings: Vec = (0..400) + .map(|i| left_eye_off(i, sweep(i, 100), if i < 10 { [1.1; 2] } else { [0.06, 1.1] })) + .collect(); + let expected = gaze_angles([0.4, 0.0, -1.0])[0]; + let gaze = run(&own_share(1.0), &readings).last().unwrap().gaze; + assert!((gaze[0] - expected).abs() < 0.02 && (gaze[2] - expected).abs() < 0.02, "{gaze:?}"); + // Halfway: around it by half of VERGENCE_DEG's half + let gaze = run(&own_share(0.5), &readings).last().unwrap().gaze; + let quarter = VERGENCE_DEG / 4.0 / 45.0; + assert!((gaze[0] - expected - quarter).abs() < 0.02 && (gaze[2] - expected + quarter).abs() < 0.02, "{gaze:?}"); + // An eye unreliable for its gaze alone (the quality limit) the same, in choose_gaze's place + let angles = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]; + let shared = choose_gaze(angles, [false, true], true, true); + assert_eq!(own_eye_gaze(shared, angles, [false, true], 1.0), [0.3, 0.4, 0.3, 0.4, 0.3, 0.4]); + let shared = choose_gaze(angles, [true, false], true, true); + assert_eq!(own_eye_gaze(shared, angles, [true, false], 1.0), [0.1, 0.2, 0.1, 0.2, 0.1, 0.2]); + } + fn scaled(left: f32, right: f32, up: f32, down: f32) -> Settings { Settings { gaze_scale_left: left, From 9ca44e12c20c3d0eba147af20735ea16bfff5bad Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Wed, 21 Oct 2026 23:58:55 +0900 Subject: [PATCH 13/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E7=9B=AE?= =?UTF-8?q?=E3=81=94=E3=81=A8=E3=81=AE=E8=A6=96=E7=B7=9A=E3=81=AE=E3=83=92?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=82=92=201=20=E8=A1=8C=E3=81=AB=E5=8F=8E?= =?UTF-8?q?=E3=81=BE=E3=82=8B=E5=BD=A2=E3=81=AB=EF=BC=88=E3=83=81=E3=82=A7?= =?UTF-8?q?=E3=83=83=E3=82=AF=E6=8B=85=E5=BD=93=E3=81=AE=E6=8C=87=E6=91=98?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- panel/README.md | 2 +- panel/src/i18n.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/README.md b/panel/README.md index d97e453..c82d2ed 100644 --- a/panel/README.md +++ b/panel/README.md @@ -27,7 +27,7 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ |---|---| | 基本 | 送信(送る / 止める)、送り先(3 枚のカード: VRChat に直接 / VRCFT(LiveLink)「おすすめ」 / VRCFT(FrameEyeOSC)「全部入り」。タグはカードの上の縁に。それぞれ 2 行で 同期・PC に何が要るか(`outputMarks[4][2]`。見開きの行は無い)。FrameEyeOSC の同期は日本語版が二重丸(`ICON_DOUBLE_RING`)、英語版が「✓ Synced, pupils too」。選んだカードは太い枠と左上の ✓、コマンドで固定中は灰色。カードの下に小さな[VRCFT(ETVR)](高さ 40、選んでいれば ✓ とオレンジの枠)と「削除予定」、ETVR の 2 行を「 ・ 」でつないで 1 行で。「VRChat に直接」を選んでいるときは、その小さなボタンの上に灰色の 1 行「目が動かないアバターは、VRCFT(LiveLink か FrameEyeOSC)にすると動きます」(`vrchatDirectHint`)。ETVR を選んでいるときだけ、カードとボタンのあいだにオレンジの案内「いまは VRCFT(ETVR)に送っています。次の版で ETVR はなくなります」「上の 3 つから、ほかの送り先を選び直してください」〔`outputDeprecated`〕。勝手にほかの送り先へは移さない)、目のカメラ(準備前は「使っていない」と[はじめる →](目のカメラタブへ。タブがあるときだけ)。準備後は「使う / 使わない」(`camera_lids`。0.7.6 までは目のカメラタブの「カメラで瞼を取る」)と短い状態: 左・右 OK / 左だけ / 右だけ / オフ / 目を覚えてる途中 / 待機中(`cameraBrief`))、言語(日本語 / English。右に[すべて既定に戻す])、SteamVR と一緒に起動(オン / オフ。右に[アプリを終了])。いちばん下の「押すとすぐ本体に反映され…」は入るときだけ(ETVR の案内が出ているときは省く) | | 送り方 | 送り先の PC(自動 / 今の相手で固定 / IP を入力。手で決めた host は 3 つ目に「手動 192.168.1.20」と出る)、ポート(− / +、既定に戻す。ヒントに送り先の種類の既定)。VRChat に直接のときだけ パラメーター名の頭(/FT / なし、送るアドレスの例)、EyeTrackingActive の型(Bool / Float / 送らない、`eye_tracking_active`)、Steam Link の名前も送る(オン / オフ、`steamlink_params`。Steam Link の OSC 向けのアバター用。送るアドレスの例に「頭はつけない」)、VRChat 標準の目も動かす(オン / オフ、`native_eyes`。VRCFT 用の値がないアバター用)。LiveLink / FrameEyeOSC / ETVR のときは「PC の VRCFT で準備すること」の手順(LiveLink と ETVR は 3 つ。FrameEyeOSC は 4 つ: PC のブラウザーで zip を落とす・「Module Registry」の[Install from .zip]・ほかの目のモジュールはオフ・UDP 11112。1 つ目の下にアクセント色の入手先 github.com/sasaken1102r/VRCFT-FrameEyeOSC/releases があり、押すと Frame の Chromium でそのページを開く〔`setup_tools::kModulePageUrl`〕。開けなかったら、そのすぐ下に赤の 1 行「ブラウザーを開けませんでした: 理由」〔目のカメラの準備の画面には出さない。次に開けたときと送り先が変わったときに消える。`--fake-module-link-error`〕)と注記(入りきらないとき〔LiveLink で目のカメラを使っているとき〕は注記を省く。FrameEyeOSC で目のカメラを使っているときは「瞳孔の大きさも、VRCFT がアバターに合った形で送ります。」を足す)。FrameEyeOSC には瞳孔の行が無い(VRCFT が送る)。目のカメラを使っていないときだけ点線の 1 行。目のカメラを使っているとき(`eyecam::inUse`: 準備が済んでいて `camera_lids` がオン)は、LiveLink なら「瞳孔は VRChat に直接送る」(`pupils_to_vrchat`)、VRChat に直接と LiveLink なら「瞳孔の受け取り方」(小数 / 1 / 2 / 3 / 4、`pupil_bits`。PupilDilation1・2・4・8 の bool も送る個数。LiveLink で `pupils_to_vrchat` がオフのときは灰色で押せない)。どちらも緑の「カメラ」バッジ付き。使っていないときはその場所に点線の 1 行「目のカメラを使うと、瞳孔の大きさも送れるよ」(押すと目のカメラタブ。`pupilsLineShown`) | -| 視線 | いちばん上の切り替えで 3 つの小タブ(詳細・まぶたタブと同じ形。初めては「動き」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `gaze_page` で覚えている)。「動き」: スムージング(オン / オフ)、なめらかさ(弱 / 中 / 強)、細かく変える(止まっている時・速い動き・変化の感度の 3 つを − / +)、見つめている時の遊び(角度も表示)。「動く範囲」: 視線の倍率(左・右、上・下の − 100% +。5〜200 %、5 % 刻み、`gaze_scale_left/right/up/down`。ヒント「目合わせのあとに掛けます」。左右・上下のあいだに鍵: 閉じている(アクセント色)あいだは片方を変えるともう片方も同じ値に、開く(灰色)と別々、閉じ直すと右 := 左・下 := 上(`gazeScaleStep` / `gazeScaleRelink`)。鍵は `panel.json` の `gaze_scale_link_x` / `_y` に覚え、起動時に値が違う組は開いて見せる(`gazeScaleLinkShown`)。どちらかがコマンドで固定中なら鍵は押せない)、左右の目を別々に動かす(Frame が片目だけで追っているあいだは、ヒントが「片目だけ追跡中は両目が同じ向き」になる)、その下に目ごとの視線(`own_eye_gaze` の − / +、0〜100 %、10 % 刻み。右に「0 % で安定、100 % で目ごとそのまま(ぶれる)」。「別々に動かす」がオフのときは薄くして押せなくし、v1 のときは薄くしたうえで右を「v2 だけで効くよ。今は v1 なので使ってない」と[詳細 › バージョンへ]に)。「乱れ対策」: まばたき中は視線を止める(オン / オフとしきい値)、不確かな視線を使わない(オン / オフと上限)、一瞬の途切れを消す(オン / オフ)。`--gaze-page motion|reach|glitch` で小タブを選んで描き、`--fake-gaze-scale L,R,U,D` で倍率を入れる | +| 視線 | いちばん上の切り替えで 3 つの小タブ(詳細・まぶたタブと同じ形。初めては「動き」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `gaze_page` で覚えている)。「動き」: スムージング(オン / オフ)、なめらかさ(弱 / 中 / 強)、細かく変える(止まっている時・速い動き・変化の感度の 3 つを − / +)、見つめている時の遊び(角度も表示)。「動く範囲」: 視線の倍率(左・右、上・下の − 100% +。5〜200 %、5 % 刻み、`gaze_scale_left/right/up/down`。ヒント「目合わせのあとに掛けます」。左右・上下のあいだに鍵: 閉じている(アクセント色)あいだは片方を変えるともう片方も同じ値に、開く(灰色)と別々、閉じ直すと右 := 左・下 := 上(`gazeScaleStep` / `gazeScaleRelink`)。鍵は `panel.json` の `gaze_scale_link_x` / `_y` に覚え、起動時に値が違う組は開いて見せる(`gazeScaleLinkShown`)。どちらかがコマンドで固定中なら鍵は押せない)、左右の目を別々に動かす(Frame が片目だけで追っているあいだは、ヒントが「片目だけ追跡中は両目が同じ向き」になる)、その下に目ごとの視線(`own_eye_gaze` の − / +、0〜100 %、10 % 刻み。右に「0 % で安定、100 % で目ごと(ぶれる)」。「別々に動かす」がオフのときは薄くして押せなくし、v1 のときは薄くしたうえで右を「v2 だけで効くよ。今は v1 なので使ってない」と[詳細 › バージョンへ]に)。「乱れ対策」: まばたき中は視線を止める(オン / オフとしきい値)、不確かな視線を使わない(オン / オフと上限)、一瞬の途切れを消す(オン / オフ)。`--gaze-page motion|reach|glitch` で小タブを選んで描き、`--fake-gaze-scale L,R,U,D` で倍率を入れる | | 目を合わせる | [目を合わせる](ヒント「正面・上の幅・まぶた・約 20 秒」。合わせたあとは[もう一度合わせる])と小さい[真ん中だけ合わせ直す]、「被ったとき」の行(何もしない / 真ん中だけ、`auto_recenter`。既定は真ん中だけ。0.7.6 までの `"tilt"` は `"center"` として読む)、今どうなっているか(始め方・結果[元に戻す]・止めた / 失敗の理由。被ったときの合わせ直しをダッシュボードが止めて、もう一度やるときは「ダッシュボードを閉じたら、もう一度合わせ直します」も)。目を合わせている間はタブ全体が「点を見てください」と何をするか(動かない文字)と大きな[やめる]だけになり、左の列とタブの並びは暗くなる(押せるのはタブと[やめる]だけ。ほかのタブを押すと止まる)、[♪ 音を鳴らす: オン / オフ]、「細かく直す」(開閉。[視線 / まぶた]で切り替え。視線: 正面の位置・動く幅(左右・上・下)、真下で左右を止める角度。まぶた: 目ごとの読んだ値 閉じ / 上 / 正面 / 下 の − / +。閉じているあいだは、その下に「合わせ方」の箱: 被るたびは真ん中の点を 1 つ見るだけ(自動)、[目を合わせる]は最初と気になったときだけ、傾きは使わなくなったこと) | | まぶた | 見た目の調整を全部ここに(案E)。いちばん上の切り替えで 4 つの小タブ(詳細タブと同じ形。初めては「見た目」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `lids_page` で覚えている)。「見た目」: 「いまのまぶた」の帯(目のカメラ(左・右)/ 目のカメラ(左)+ Valve(右)/ 目のカメラ(待機中)/ Valve の値。準備が済んでいれば右に「オン・オフは「基本」タブで」)、「見開き」の行(する / しない で `widen` を `SetBool`。カメラを使っていてもいなくてもいつも出す。ヒント「閉じ・細めはそのまま」。「しない」のあいだは下の「見開きの出やすさ」を灰色にして押せなくし、その下の 1 行を「「見開き」が「しない」なので、いまは使わないよ」に。目のカメラタブのいちばん下の文も「カメラで両目を見て、まぶたを送ってるよ(見開きはしない)」に、診断の「まぶた」の行には「· 見開きしない」を足す。`--fake-no-widen` で描ける)、目のカメラを使っているときだけ「見開きの出やすさ」のスライダー 1 本(緑の「カメラ」バッジ。鈍い ↔ 敏感。両目がカメラなら eyecam-rec の `widen_sensitivity` を `set widen_sensitivity` で送る。片目だけカメラなら同じく感度を送り、Valve の目には近い `lid_widen` の段階も一緒に書く。Valve の値なら `lid_widen` の 4 段階(しない / 控えめ / ふつう / 出やすい)で、押した所に近い止まり位置へ。目を合わせていない目があると下に「目を合わせていない目は、「細かく直す」の目盛りで決まるよ」。いまカメラの目が無く、ふつうに開いた目が 1.0 と読まれる SteamOS(`openness_saturated`)では、スライダーの代わりに 1 行: ヒント「この SteamOS では効かないよ」、「Valve の値だけだと見開きが出ないよ」と[目のカメラへ →](目のカメラタブがあるときだけ))、目のカメラを使っていて eyecam-rec の status.json に `lid_veto.on` があるときだけ「半目の読み直し(AI)」(する / しない。緑の「カメラ」バッジ。eyecam-rec に `set lid_veto on|off` を送り、選ばれているほうは status.json の `lid_veto.on` のまま〔panel.json には持たない〕。下に 3 行までの説明。`--fake-eyecam …:veto=on|off` で描ける)、まぶたのなめらかさ(弱 / 中 / 強。中 = 本体の既定値、どれにも合わなければカスタム)、左右をそろえる強さ(`lid_sync` のスライダー、0.05 刻み)。目のカメラを使っていないときは、いちばん下に点線の 1 行「目のカメラを使うと「見開きの出やすさ」が増えるよ」(押すと目のカメラタブ)。なので、カメラが無いと `lid_widen` はパネルから変えられない。「まばたき・細め」: まばたきを届ける(保持 ms の − / +)、まばたきを両目でそろえる(`blink_sync_below` の − / +、0 でオフ)、細めたときのまぶたの下限(`camera_lid_floor` の − / +、0.05 刻み、0 でオフ。目のカメラを使っているときだけ出す。緑の「カメラ」バッジ。カメラの目がまだないときは「目のカメラが目を見てるときに効くよ」と添える。使っていないときは点線の 1 行「目のカメラを使うと「細めたときのまぶたの下限」も使えるよ」)。それぞれ右に説明。「細かく直す」: いちばん上に目盛りの決まり方の 1 行、自動キャリブレーション(目を合わせていない目で、カメラでないとき)、左右の倍率、生の値の棒と 4 つの目盛り(③④は目を合わせた目とカメラのときは灰色)、ほぼ開いてたら全開にする(⑤。生の値の棒にも ⑤ の青い破線を出す。目を合わせた目は倍率をかけた位置、カメラが読んでいる目は薄く、0.75 のときは出さない。行の数字と − / + の縁も同じ青。`lid_open_snap` の − / +、送るまぶたの値で 0〜0.75 の 0.01 刻み、0.75 でオフ。いつも出す。右に説明。開いた値に対する割合だったころの 0.75 より大きい値は、起動時に一度だけ置き換える(`migrateLidOpenSnap`))、なめらかさの 2 つの値(止まっている時・速い動き。ページに収まるよう、それぞれの左に名前を置いた 1 行)。目のカメラタブの[まぶたへ →]は「見た目」を開く。「眉」: 「見開きで眉も上げる」(する / しない で `brow_from_widen` を `SetBool`。ヒント「カメラは眉を見ていないよ」。カメラを使っていてもいなくても、「見開き」が「しない」でもいつも出す。下に 3 行までの説明〔`\n` の所で改行〕)と、「する」のあいだだけ「眉の上がり方」(`brow_strength` のスライダー、弱い ↔ 強い、0.05 刻み。ヒント「アバターに合わせて」)。`--fake-brows` で「する」を描ける。`--lids-page look|blinks|fine|brows` で小タブを選んで描く(`--lid-marks` は fine と同じ) | | 目のカメラ | eyecam-rec が動いているあいだだけ出る(「詳細」の前。出る条件は下の「目の撮影」と同じ)。準備を始める前(準備が済んだことがなく、まだ ① か ② の段で、このパネルを起動してから[はじめる]を押していないとき。`eyecam::introShown`)は、「目のカメラでできること」(見開き・瞳孔の大きさ・Valve がまぶたを低く読んだときにカメラで確かめて直す)、1 回だけの準備と目の映像は Frame の外に出さないことの箱、[はじめる(約 2 分)]、「使わなくても、目の送信はふつうに動きます」。押したあと(と準備が進んでいるとき)は準備のチェックリスト(いまの段階は、満たしていない最初のもの。毎回 status.json とパスワードの確認だけで決める): ① パスワードを決める(SteamOS にパスワードが無いと分かっているときだけ。`steamos-passwd --has-password` を裏で数秒おきに確かめる。読むだけ。[Konsole で passwd を開く])② 道具を入れる([Konsole で開く]で `sudo $HOME/.local/lib/eyecam/install_grab.sh` が入力済みの Konsole を開くだけ。Enter とパスワードは本人で、パネルは sudo も passwd も実行しない。`auto_grab` が ok / trying / waiting_tracker / failed: … か、バッファを持っていれば入っている扱い。`auto_grab` が `too_old`(eyecam の安全のための下限より古く、起動されない)なら入っていない扱い。`grab_outdated` が true(更新で新しい道具が届いた)か too_old なら、見出しを「道具を入れ直す」、その下に「道具が新しくなったよ、入れ直してね」)③ 目の動きを覚える(カメラの映像は「届いてるよ」、`cam_fps` が毎秒 60 枚より少なければアクセント色で「届いてるよ(毎秒 15 枚・少なめ)」。目ごとに「見えてるよ」(status.json の `pupil_l` / `pupil_r` で瞳が 50 % 以上)・「映像はあるけど瞳が見つからない」(`locked` だけど 20 % 未満、赤)・「瞳がときどきしか見えない」(そのあいだ)・「見えてないよ」(`locked` が false)。`pupil_l` / `pupil_r` の無い古い eyecam-rec では今までどおり `locked` だけで決める。目の映像が届いているとき(`locked`)は[覚えはじめる]で `calib wear` を押せて、瞳が見つからない目があるときはその下に赤字で「HMD をかぶり直して」と出す。18 秒の間、ステップのチップ・残り秒・[やめる]をカードの中に出す。失敗したら eyecam-rec のメッセージ(数も入っている)の上に、メッセージが名前を出した目(「うまくいかなかった目」)を出す。見開きだけ取れなかったら[このまま進む][もう一度(18秒)]。片目だけ失敗して通ったとき(`calib_failed_eye` が L / R)は「左目は前の値を使うよ」(初めてなら「仮の値」)と目ごとの結果、メッセージ、[使いはじめる][もう一度(18秒)])④ 完了(「使いはじめる」)。セットアップ済みでも道具が無くなれば ② に戻り、直れば校正し直さずにふだんの画面へ。道具が古いだけなら ② には戻さず、ふだんの画面のいちばん上にお知らせのカードと[Konsole で開く](`grab_outdated`: 「道具が新しくなったよ、入れ直してね(今の道具のままでも動くよ)」、`too_old`: 赤いカードで「安全のため、道具を入れ直してね。入れ直すまでカメラは止まってて、まぶたは Valve の値だけで送ってるよ」)、左の列にも同じお知らせ(押すとこのタブ)。eyecam-rec が言わなくなれば自然に消える(`eyecam::toolState` / `toolNotice`。`--fake-eyecam …:outdated`、`waiting:auto=too_old:setup:outdated` で描く)。左の列には、準備が済むまで「次にやること・目のカメラ」、済んだ直後に緑の「目のカメラ:準備できたよ」。済んだあとのふだんの画面: いまの状態(両目・片目・Valve の値とその理由。ヒントは「オン・オフは「基本」タブで」。カメラのスイッチ `camera_lids` は「基本」タブに移した。オフのあいだの校正のボタンは「「基本」タブで目のカメラを「使う」にしてから」)、その下の行の右に[普段の目を覚え直す](英語は Relearn my eyes。左に「約 30 秒。そのあいだも今の値で動くよ」。eyecam-rec に `relearn` を送り、覚え始めがやり直しになって、いまの状態の右のピルが「普段の目を覚えてるよ(あと N 秒)」に戻る。覚えているあいだも、もう覚えていた目は前の値でまぶた・見開き・細めを送り続ける(`calib_state` の 4 はそのまま)。押せるのは `eyecam::relearnAllowed`: eyecam-rec の状態が新しく、idle・live・目の映像あり〔`locked`〕・`baseline` を書く eyecam-rec、`camera_lids` がオン、カメラがまぶたを動かしているか覚えている途中〔`relearnUse`〕、ほかのコマンドの返事待ちでない)、違和感があるときの[目のカメラの校正(18秒)]、細めも送るときの[ユーザー校正(最初に 1 回)](どちらも ③ と同じカードで進み、終わると結果のカード)、こんなときは。見開きの出やすさは「まぶた」タブ。`--fake-eyecam` と `--fake-password`、`--fake-camera` で描く | diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index ba1cd3b..b5e9fa4 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -222,7 +222,7 @@ UiText makeJapanese() { t.hintIndependent = "少し寄り目にして、上下は目ごと"; t.hintIndependentOneEye = "片目だけ追跡中は両目が同じ向き"; t.rowOwnEyeGaze = "目ごとの視線"; - t.hintOwnEyeGaze = "0 % で安定、100 % で目ごとそのまま(ぶれる)"; + t.hintOwnEyeGaze = "0 % で安定、100 % で目ごと(ぶれる)"; t.rowQuality = "不確かな視線を使わない"; t.hintQuality = "この値より不確かな目は無視"; t.rowDespike = "一瞬の途切れを消す"; @@ -1188,7 +1188,7 @@ UiText makeEnglish() { t.hintIndependent = "Turned in a little, up/down per eye"; t.hintIndependentOneEye = "Both follow the one tracked eye"; t.rowOwnEyeGaze = "Own gaze per eye"; - t.hintOwnEyeGaze = "0 % steady, 100 % each eye as tracked (wobbly)"; + t.hintOwnEyeGaze = "0% steady, 100% each eye as tracked (wobbly)"; t.rowQuality = "Skip unreliable gaze"; t.hintQuality = "Ignores an eye less sure than this"; t.rowDespike = "Remove glitches"; From ce2e69b4c811073b79570543a5ba4740083a4cad Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Thu, 22 Oct 2026 00:00:17 +0900 Subject: [PATCH 14/65] =?UTF-8?q?0.8.2-beta.1:=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=B1=A5=E6=AD=B4=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.ja.md | 10 ++++++++++ CHANGELOG.md | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 8b61fd1..009ecf8 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -1,5 +1,15 @@ # 更新履歴 +## 0.8.2-beta.1 (2026-10-22) + +SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。 + +- 視線: どちらかの目の開き具合が `gaze_hold_below`(0.5)より小さい間は、まばたきのために視線を止めています。SteamOS は開いた片目を 0.06 くらいと何分も何時間も読み続けることがあり(度付きインサートのときなど)、そのあいだ、まぶたと瞳孔は動くのに視線だけ止まっていました。今は、片目だけが線より下のときは 1 秒まで(ウインク)止め、それより長いとその目の読みは誤りとみなして止めず、もう片方の目で両目を動かします。まばたきは今までどおり止め、閉じたと読まれ続けている目があっても、まばたきのたびにまた 1 秒止まることはありません。1 サンプルだけ開いたと読まれても、1 秒はやり直しになりません +- 新しい設定 `own_eye_gaze`(既定 0、0〜1。`--own-eye-gaze`)。視線 › 動く範囲の「左右の目を別々に動かす」の下の「目ごとの視線」(0〜100 %、10 % 刻み)です。「左右の目を別々に動かす」がオン(v2)のとき、目ごとの左右の視線を、2° 寄せた共通の視線(0 %、今まで通り)から、Frame が追った目ごとの左右の視線そのまま(100 %)のあいだで混ぜます。Frame の目ごとの左右の視線はぶれるので、上げるほどアバターの目がぴくつくことがあります。上下・まぶたなどはそのままで、閉じたと長く読まれている目(上)や視線が当てにならない目は、今までどおりもう片方の目の視線を使います。0 なら送るものは今までと同じです。「左右の目を別々に動かす」がオフのときと v1(もともと目ごとの視線をそのまま送ります)のときは薄く表示されます +- eyecam: 急に普段の 1.3 倍より大きく写った瞳(耳側を大きく見るとカメラの方を向き、暗くなると瞳が開く)は、まばたきの直後に大きさの確認で落とされ、小さく戻るまで見失ったままでした(ある録画では 7.4 秒)。今は、そういう瞳が開いた目の瞳と同じくらい暗く欠けがなく、平たくもなっていなくて、0.3 秒見え続けたら採り、普段の大きさもそこから測り直します。録画 26 本で流し直すと、開いた目のフレームで瞳が見つかったのは 88.8 % から 89.0 %、閉じの取りこぼしは 1 分あたり 0.402 回から 0.386 回で、閉じ・細め・見開きは変わりませんでした +- 目のカメラを使ったまぶた(SteamOS 0.4.x): SteamOS の目のトラッキングは開いた目をカメラよりかなり低く読むことが多く、両者がそろわないまま、まばたきのたびにカメラの遅れの分だけまぶたの「疑い」が積み重なって、最後はカメラがずっとまぶたを動かすようになり、カメラが見逃したまばたきは開いたまま送られていました。今は、SteamOS が目を 1 秒開いていると読んだら疑いをやり直します。流し直しでは、右目がカメラのまぶたになっていた時間が 0.67 時間のうち 130 秒から 111 秒になり、まぶたのサンプルの 0.3〜0.6 % が変わりました +- eyecam、ユーザー校正: 細めは、そのかぶりの「開いた〜閉じた」の幅に対する割合で保存されていたのに、使うときは普段の幅に掛けていました。この幅はかぶるたびに 0.6〜1.5 倍違うので、校正した細めの深さが 0.65〜1.5 倍ずれていました。今はユーザー校正で細めの深さそのものも保存し(各目のユーザーの値の `sq_depth`)、使うときもそれを、校正と同じ上限・下限の中で使います。案内に沿った「普通」の段で開いた目が細めと読まれたのは 2.4 % から 0.8 % になり、いちばん細めたときは少し低めに読まれます(0.9 以上が 53 % から 35 %)。新しくユーザー校正をしたあとから効き、前の校正は今まで通り動きます。細めが浅すぎると感じたら、ユーザー校正をやり直してください。前の版に戻してそこで校正を保存すると `sq_depth` が消えるので、更新したあとにユーザー校正をやり直してください + ## 0.8.1 (2026-10-21) 目のプレビューの「Valve」/「カメラ」の表示が、実際にまぶたを動かしている側を示すようになりました。目のカメラは、暗い映像や度付きインサートでも、閉じたのを取りこぼすことが減りました。テスト版を使っていて「テスト版も受け取る」をオフにすると、詳細 › バージョンから本番の版に戻せるようになりました。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 488de37..feadbb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.8.2-beta.1 (2026-10-22) + +The gaze no longer stays frozen when SteamOS keeps reading one open eye as closed: after a second, the other eye moves the gaze (#32). With "Move eyes separately" on, the new "Own gaze per eye" (Gaze › Reach) lets each eye's own sideways gaze through, so looking inward with both eyes can show. The eye cameras no longer lose a pupil that suddenly looks larger, the camera eyelids no longer take over for good after many blinks on SteamOS 0.4.x, and a new user calibration now sets the squint at the same depth whatever the wear. + +日本語: SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。 + +- Gaze: the gaze is held while either eye's openness is below `gaze_hold_below` (0.5), for blinks. SteamOS can read one open eye at about 0.06 for minutes to hours (with prescription inserts, for example), and the gaze stood still all that time while the eyelids and pupils kept moving. Now one eye alone below the line holds the gaze for up to 1 s (a wink); longer, and that eye's reading is taken as wrong: it no longer holds the gaze, and the other eye moves both. Blinks are held as before, and an eye read closed for good is not held again for a second after each blink. A single sample read open doesn't start the second over. +- New setting `own_eye_gaze` (0 by default, 0 to 1; `--own-eye-gaze`), "Own gaze per eye" on Gaze › Reach under "Move eyes separately" (0-100 %, 10 % steps). With "Move eyes separately" on (v2), it blends each eye's sideways gaze from the shared one turned in by 2° (0 %, as before) to each eye's own sideways gaze as the Frame tracks it (100 %). The Frame's per-eye sideways gaze wobbles, so the avatar's eyes may twitch at higher values. Up/down, the eyelids and everything else stay as they are; an eye read closed for long (above) or with an unreliable gaze still takes the other's. At 0 everything sent is as before. It is greyed out while "Move eyes separately" is off, and in v1 (which already sends each eye's own gaze). +- eyecam: a pupil that suddenly looks more than 1.3 times as large as usual (looking far toward your ear turns it toward the camera; a darker world dilates it) was dropped by the size check right after a blink, and stayed lost until it shrank again (7.4 s in one recording). Now, when such a pupil is as dark and as whole as the open one and no flatter, and is seen for 0.3 s, it is taken and the usual size starts over from it. Replayed on 26 recordings: the pupil found in 89.0 % of open-eye frames instead of 88.8 %, closes missed 0.386 instead of 0.402 a minute, closing, squint and widening the same. +- Eyelids with the eye cameras on SteamOS 0.4.x: its eye tracker often reads an open eye well below the camera, so the two never agreed and each blink's camera lag added to the eyelid's "doubt" until the camera drove the eyelid for good, and blinks the camera missed went out open. The doubt now starts over once the eye tracker has read the eye open for 1 s. In replays the right eye's time on the camera eyelid went from 130 s to 111 s in 0.67 h; 0.3-0.6 % of eyelid samples changed. +- eyecam, user calibration: the squint was saved as a fraction of that wear's open-to-closed gap but used against the usual gap, which differs by 0.6 to 1.5 times from wear to wear, so the calibrated squint came out 0.65 to 1.5 times as deep. The user calibration now also saves the squint's depth (`sq_depth` in each eye's user values), and live uses it, kept within the same limits as the calibration's. Open eyes read as a squint went from 2.4 % to 0.8 % in the guided normal step; a full squint reads a little lower (0.9 or more 35 % of the time instead of 53 %). This takes effect after a new user calibration; older calibrations work as before. If your squint now reads too shallow, run the user calibration again. Going back to an older version and saving a calibration there drops `sq_depth`, so run the user calibration again after updating. + ## 0.8.1 (2026-10-21) The eye preview's Valve / Camera tag now shows what really drives each eyelid, and the eye cameras miss far fewer closes on dim pictures or with prescription inserts. With "Get test versions" turned off on a test version, Advanced › Version now offers going back to the regular version. From cde7b8e91890a564cc40cbb877263edf0f079c5d Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Thu, 22 Oct 2026 23:15:53 +0900 Subject: [PATCH 15/65] =?UTF-8?q?eyecam:=20=E9=96=89=E3=81=98=E3=81=9F?= =?UTF-8?q?=E7=9B=AE=E3=81=A7=E4=B8=8B=E3=81=BE=E3=81=B6=E3=81=9F=E3=81=AE?= =?UTF-8?q?=E7=B7=9A=E3=81=8C=E5=A4=A7=E3=81=8D=E3=81=8F=E4=B8=8B=E3=81=AB?= =?UTF-8?q?=E5=A4=96=E3=82=8C=E3=81=9F=E3=82=89=E3=80=81=E6=9C=80=E5=BE=8C?= =?UTF-8?q?=E3=81=AB=E7=9E=B3=E3=81=82=E3=82=8A=E3=81=A7=E8=A6=8B=E3=81=9F?= =?UTF-8?q?=E4=B8=8B=E3=81=BE=E3=81=B6=E3=81=9F=E3=81=AE=E7=B7=9A=E3=82=92?= =?UTF-8?q?=E4=BD=BF=E3=81=86=EF=BC=88issue=20#38=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 度付きレンズのインサートの人の左目は、校正の閉じで下まぶたの線が開いた目より 0.98 r(中央値)下に外れ、閉じた目が 0.95 r 開いていると読まれて、開きの幅が 0.30 になっていた(右目は閉じると線が重なる)。 - 瞳なしで見つけた下まぶたの線が、最後に瞳ありで見た線より 0.9 r 以上下なら、その最後の線を使う(下まぶたはまばたきでほとんど動かない)。開発者の録画 26 本では、瞳なしの線の 0.5%(その 9 割は閉じた目) - 下まぶたの線が探す帯の下端に張り付いたら(上まぶたの線と同じ決め方)、線なしとして同じく最後の線を使う - 瞳が丸ごと見えている(光線の 75% 以上が縁に届く)のに上まぶたの線が瞳の中心より下なら、線なしにする(同じ人の左目の見開きで半分以上のフレーム。開発者の録画では 0.001%) - 校正のやり直し(reset_tracking)で、持っている下まぶたの線も忘れる Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/src/feat.rs | 104 +++++++++++++++++++++++++++++++++++---- tools/eyecam/src/live.rs | 88 +++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 9 deletions(-) diff --git a/tools/eyecam/src/feat.rs b/tools/eyecam/src/feat.rs index 9c3b837..4de87a4 100644 --- a/tools/eyecam/src/feat.rs +++ b/tools/eyecam/src/feat.rs @@ -55,6 +55,15 @@ const EXT_MAX_A: f64 = 60.0; /// Not without the pupil: a closing lid really does come down to the band's bottom. pub const LID_EDGE_SHARE: f64 = 0.5; const LID_EDGE_ROWS: usize = 1; +/// The lower lid line has the same rule at the far (bottom) edge of its band only, with or without the pupil: a line +/// pushed against cy + 1.9 r is no lower lid, while a closing eye's lines do meet at the band's top. The developer's +/// recordings: lower lines with the pupil within 0.40 .. 1.44 r of its centre, without it 0.19% of frames past 1.8 r. +/// The engine uses the last lower line seen with the pupil instead (and for one found without the pupil far below it, +/// live::LOWER_DROP_MAX). +/// And the upper line cannot be below the pupil's centre while the pupil is seen whole (UPPER_WHOLE_VIS of its rays +/// reach its edge: a lid that low hides its upper half): the same wearer's left eye had its upper line at -0.25 r, under +/// the centre, on more than half of its widen frames; the developer's recordings, 0.001% of such frames. +const UPPER_WHOLE_VIS: f64 = 0.75; /// The calibration's second, higher search band for a widened upper lid (`Extractor::upper_high`): rows from /// cy - HIGH_BAND.0 r to cy - HIGH_BAND.1 r (the usual band is cy - 2.1 r .. cy + 0.45 r). See live::widen_fit. pub const HIGH_BAND: (f64, f64) = (3.2, 1.6); @@ -208,7 +217,7 @@ pub struct Pupil { } /// One lid line: quadratic over the iris columns, its height at the pupil column, inlier share, edge strength, and -/// the share of its inliers on the search band's first or last rows (LID_EDGE_SHARE). +/// the share of its inliers on the search band's first or last rows (LID_EDGE_SHARE), and on its last rows only. #[derive(Clone, Copy, Debug)] struct Lid { coef: Quad, @@ -216,6 +225,7 @@ struct Lid { frac: f64, grad: f64, edge: f64, + edge_far: f64, } impl Lid { @@ -223,6 +233,11 @@ impl Lid { fn on_edge(&self) -> bool { self.edge >= LID_EDGE_SHARE } + + /// Pushed against its band's far (last) rows: no lower line. + fn on_far_edge(&self) -> bool { + self.edge_far >= LID_EDGE_SHARE + } } /// The per-frame features (NaN = not measured), named as in the prototype's CSV. @@ -242,9 +257,12 @@ pub struct Features { pub upper_frac: f64, pub lower_frac: f64, pub box_mean: f64, - /// The pupil was seen, and the upper lid line found was pushed against its band's edge (LID_EDGE_SHARE): left out, - /// the upper lid has no line on this frame. + /// The pupil was seen, and the upper lid line found was pushed against its band's edge (LID_EDGE_SHARE) or lay below + /// the centre of a pupil seen whole (UPPER_WHOLE_VIS): left out, the upper lid has no line on this frame. pub upper_edge: bool, + /// The lower lid line found was pushed against its band's far edge: left out, no lower line on this frame (live also + /// sets it when it replaces a line found far below the last one, live::LOWER_DROP_MAX). + pub lower_edge: bool, /// How the search went (not part of the prototype's features). pub diag: Diag, } @@ -269,6 +287,7 @@ impl Default for Features { lower_frac: n, box_mean: n, upper_edge: false, + lower_edge: false, } } } @@ -604,7 +623,8 @@ impl Extractor { f.upper_y = u.y_at + margin.unwrap_or(MARGIN_OFF); f.upper_frac = u.frac; } - if let Some(l) = lower { + f.lower_edge = lower.is_some_and(|l| l.on_far_edge()); + if let Some(l) = lower.filter(|l| !l.on_far_edge()) { f.lower_y = l.y_at; f.lower_frac = l.frac; } @@ -620,13 +640,15 @@ impl Extractor { f.iris_n = n; let rn = r_ref.unwrap_or(if r.is_finite() { r } else { 2.0 * p.a }); let (upper, lower, margin) = self.lids(&p, rn, xmax); - f.upper_edge = upper.is_some_and(|u| u.on_edge()); - if let Some(u) = upper.filter(|u| !u.on_edge()) { + let no_line = |u: &Lid| u.on_edge() || (p.vis >= UPPER_WHOLE_VIS && u.y_at > p.cy); + f.upper_edge = upper.is_some_and(|u| no_line(&u)); + if let Some(u) = upper.filter(|u| !no_line(u)) { f.upper_skin_y = u.y_at; f.upper_y = u.y_at + margin.unwrap_or(MARGIN_OFF); f.upper_frac = u.frac; } - if let Some(l) = lower { + f.lower_edge = lower.is_some_and(|l| l.on_far_edge()); + if let Some(l) = lower.filter(|l| !l.on_far_edge()) { f.lower_y = l.y_at; f.lower_frac = l.frac; } @@ -1010,7 +1032,9 @@ impl Extractor { let grad = if sw.is_empty() { 0.0 } else { vision::median(&mut sw) }; let at_edge = path.iter().zip(&w).filter(|(y, k)| **k && (**y <= LID_EDGE_ROWS || **y + 1 + LID_EDGE_ROWS >= ny)).count(); let edge = at_edge as f64 / n_in.max(1) as f64; - Some(Lid { coef, y_at: coef.at(p.cx), frac, grad, edge }) + let at_far = path.iter().zip(&w).filter(|(y, k)| **k && **y + 1 + LID_EDGE_ROWS >= ny).count(); + let edge_far = at_far as f64 / n_in.max(1) as f64; + Some(Lid { coef, y_at: coef.at(p.cx), frac, grad, edge, edge_far }) } /// The calibration's second search for a widened upper lid, on the frame `extract` just ran on (its pupil `p`): @@ -1228,13 +1252,18 @@ mod tests { /// A pupil at (250, 200), radius 25, on skin that is 150 above row `step` and 120 from it down (no iris). fn eye_with_step(step: usize) -> Vec { + eye_with_step_r(step, 25.0) + } + + /// `eye_with_step` with a pupil of radius `pr`. + fn eye_with_step_r(step: usize, pr: f64) -> Vec { let mut img = vec![0u8; W * H]; for y in 0..H { for x in 0..W { let d = (x as f64 - 250.0).hypot(y as f64 - 200.0); img[y * W + x] = if x >= 350 { 4 - } else if d < 25.0 { + } else if d < pr { 12 } else if y < step { 150 @@ -1278,6 +1307,63 @@ mod tests { assert!(ex.lid_line(&pupil, (150, 300), (100, 160), (100, 160), true).is_none()); } + /// A pupil at (250, 200), radius 25, on grey 120 that turns 160 from row `step` down (a dark -> bright step: the + /// lower lid's kind) and 150 above row 150 (the upper lid's skin line). + fn eye_with_lower_step(step: usize) -> Vec { + let mut img = eye_with_step(150); + for y in step..H { + for x in 0..350 { + if (x as f64 - 250.0).hypot(y as f64 - 200.0) >= 25.0 { + img[y * W + x] = 160; + } + } + } + img + } + + #[test] + fn a_lower_lid_line_pushed_against_the_far_edge_of_its_band_is_no_line() { + let mut ex = Extractor::default(); + // Lids at R = 55: the lower band is rows 213..304. A step through the middle of it is the line. + let (f, p) = ex.extract(&eye_with_lower_step(260), 347.0, None, Some(55.0), None); + assert!(p.is_some() && !f.lower_edge && (f.lower_y - 260.0).abs() < 3.0, "{f:?}"); + // The only step on the band's last rows: no lower line, with the pupil ... + let (f, p) = ex.extract(&eye_with_lower_step(304), 347.0, None, Some(55.0), None); + let pupil = p.expect("pupil"); + assert!(f.ok_pupil && f.lower_edge && f.lower_y.is_nan() && f.lower_frac.is_nan(), "{f:?}"); + assert!(!f.upper_edge && (f.upper_skin_y - 150.0).abs() < 3.0, "the upper line stays: {f:?}"); + // ... and without it (measured where the last pupil was). + let blank = eye_with_lower_step(304).iter().map(|&v| if v == 12 { 120 } else { v }).collect::>(); + let (f, p) = ex.extract(&blank, 347.0, Some((250.0, 200.0)), Some(55.0), Some(&pupil)); + assert!(p.is_none() && f.lower_edge && f.lower_y.is_nan(), "{f:?}"); + // Only the far edge counts for the lower line: on the band's first rows it is a line (a closing eye's lines + // meet there). + let mut at = |row: usize| { + ex.gy.iter_mut().for_each(|g| *g = 0); + for x in 150..300 { + ex.gy[row * W + x] = 100; + } + ex.lid_line(&pupil, (150, 300), (100, 160), (100, 160), false).map(|l| (l.y_at.round(), l.on_far_edge())) + }; + assert_eq!(at(100), Some((100.0, false))); + assert_eq!(at(101), Some((101.0, false))); + assert_eq!(at(157), Some((157.0, false))); + assert_eq!(at(158), Some((158.0, true))); + assert_eq!(at(159), Some((159.0, true))); + } + + #[test] + fn an_upper_lid_line_below_the_centre_of_a_whole_pupil_is_no_line() { + let mut ex = Extractor::default(); + // A bright -> dark step 20 px below the centre of a pupil (radius 12) drawn whole: no upper lid line. + let (f, p) = ex.extract(&eye_with_step_r(220, 12.0), 347.0, None, Some(55.0), None); + let p = p.expect("pupil"); + assert!(p.vis >= UPPER_WHOLE_VIS, "{p:?}"); + assert!(f.upper_edge && f.upper_skin_y.is_nan() && f.upper_y.is_nan(), "{f:?}"); + // Above the centre it is the line. + let (f, _) = ex.extract(&eye_with_step_r(180, 12.0), 347.0, None, Some(55.0), None); + assert!(!f.upper_edge && (f.upper_skin_y - 180.0).abs() < 3.0, "{f:?}"); + } #[test] fn the_higher_band_finds_a_widened_lid_above_the_usual_one() { let mut ex = Extractor::default(); diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index 1efe8a7..c8fed4b 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -119,6 +119,12 @@ const EXT_TRACK_S: f64 = 1.0; const EXT_TRACK_PX: f64 = 40.0; const EXT_A_PX: (f64, f64) = (8.0, 45.0); const EXT_VALVE_OPEN: f64 = 0.6; +/// The lower lid hardly moves in a blink: a lower line found without the pupil more than this many iris radii below +/// the last one seen with it is something else (`EyeEngine::last_lower`). Issue 38's wear calibration (a prescription +/// insert): the left eye's lower line went 0.98 r (median) further down on the closed eye, so the closed eye read 0.95 r +/// open and the open gap came out at 0.30 (its right eye, whose lines meet when closed, 1.3 at a comparable radius). +/// The developer's 26 recordings: 0.5% of the lower lines found without the pupil, 90% of them on a closed eye. +const LOWER_DROP_MAX: f64 = 0.9; /// The lid features are causal medians over this long (5 frames at 90 fps; an odd number, at least 3). const MEDIAN_S: f64 = 5.0 / FPS; /// The long-run medians (iris radius, the open eye's box level, pupil size and darkness, typical pitch) take a @@ -539,6 +545,10 @@ pub struct EyeEngine { bx: Win, pd: Win, nopupil: u32, + /// The lower lid line (px) of the last frame with the pupil and a lower line, used on frames whose lower line was + /// pushed against its band's far edge or, without the pupil, lies too far below it (LOWER_DROP_MAX): the lower lid + /// hardly moves in a blink. + last_lower: f64, /// Frames in a row the open-eye check dropped the pupil only for being large (PUPIL_GROW_S). grown: u32, box_open: LongMedian, @@ -766,6 +776,7 @@ impl Default for EyeEngine { bx: Win::new(), pd: Win::new(), nopupil: 0, + last_lower: f64::NAN, grown: 0, box_open: LongMedian::new(60.0), a_open: LongMedian::new(20.0), @@ -843,6 +854,7 @@ impl EyeEngine { self.prev = None; self.prev_p = None; self.nopupil = 0; + self.last_lower = f64::NAN; self.grown = 0; self.find_x_min = self.x_min; } @@ -1058,6 +1070,16 @@ impl EyeEngine { _ if f.iris_r.is_finite() => f.iris_r, _ => 2.0 * f.pupil_a, }; + // A lower line pushed against its band's far edge, or found without the pupil more than LOWER_DROP_MAX below the + // last one seen with the pupil, is no lower lid: that last one instead (none yet: no lower line on this frame). + if !f.ok_pupil && f.lower_y > self.last_lower + LOWER_DROP_MAX * r { + f.lower_edge = true; + } + if f.lower_edge { + f.lower_y = self.last_lower; + } else if f.ok_pupil && f.lower_y.is_finite() { + self.last_lower = f.lower_y; + } // Lid geometry in pixels (causal medians over MEDIAN_S, 5 frames at 90 fps); the outputs below are in iris // radii. let med_n = (frames_in(MEDIAN_S, fps, 3) | 1) as usize; @@ -3734,6 +3756,72 @@ mod tests { (any, last.unwrap()) } + + #[test] + fn a_lower_line_at_the_far_edge_of_its_band_keeps_the_last_lower_lid() { + // Open: the lower lid is the sclera -> skin step at row 270. + let (mut e, p, t) = seen_at((250.0, 200.0), Tune::default()); + let o = e.process(&lid_frame(Some((250.0, 200.0)), true), f64::NAN, t + 1.0 / FPS, &p); + assert!(o.f.ok_pupil && !o.f.lower_edge && (o.f.lower_y - 270.0).abs() < 3.0, "{:?}", o.f); + let held = o.f.lower_y; + // Closed (lid skin all over), with a darker stretch whose lower end is the lower band's last row: that line is + // no lower lid, the last one is used. + let bottom = (200.0 + 1.9 * e.r_est.value) as usize; + let mut shut = lid_frame(None, false); + for y in bottom - 40..bottom { + for x in 0..350 { + shut[y * vision::W + x] = 110; + } + } + let (_, o) = closes(&mut e, &p, t + 2.0 / FPS, &shut); + assert!(o.f.lower_edge && o.f.lower_y == held, "{:?}", o.f); + // A lower line inside the band without the pupil is used as it is, and not kept. + let mut shut = lid_frame(None, false); + for y in 200..250 { + for x in 0..350 { + shut[y * vision::W + x] = 110; + } + } + let (_, o) = closes(&mut e, &p, t + 20.0 / FPS, &shut); + assert!(!o.f.lower_edge && (o.f.lower_y - 250.0).abs() < 3.0, "{:?}", o.f); + assert_eq!(e.last_lower, held); + // A new calibration's clean search forgets it. + e.reset_tracking(); + assert!(e.last_lower.is_nan()); + } + + #[test] + fn a_lower_line_found_without_the_pupil_far_below_the_last_one_keeps_the_last_one() { + // Open with the lower lid at row 233 (0.6 r below the pupil's centre). + let p = Params::default(); + let mut e = EyeEngine::default(); + let open = lid_frame_between(Some((250.0, 200.0)), 130..233); + let mut t = 0.0; + for k in 0..100 { + t = k as f64 / FPS; + let o = e.process(&open, f64::NAN, t, &p); + assert!(o.f.ok_pupil && !o.f.lower_edge, "frame {k}: {:?}", o.f); + } + let held = e.last_lower; + let r = e.r_est.value; + assert!((held - 233.0).abs() < 3.0 && r.is_finite(), "{held} {r}"); + // Closed, with the only dark -> bright step 1.0 r below it (inside the band, off its far edge): the last line. + let step = |row: usize| { + let mut shut = lid_frame(None, false); + for y in row - 35..row { + for x in 0..350 { + shut[y * vision::W + x] = 110; + } + } + shut + }; + let (_, o) = closes(&mut e, &p, t, &step((held + r) as usize)); + assert!(o.f.lower_edge && o.f.lower_y == held, "{:?}", o.f); + // 0.7 r below it: a lower line. + let (_, o) = closes(&mut e, &p, t + 0.2, &step((held + 0.7 * r) as usize)); + assert!(!o.f.lower_edge && (o.f.lower_y - (held + 0.7 * r)).abs() < 3.0, "{:?}", o.f); + } + #[test] fn a_pupil_lost_at_the_temporal_edge_with_the_lids_apart_is_not_closed() { // The pupil gone, the iris box all sclera (brighter than the open eye's), the lids where they were. From 2a17dba0bef0453713bb557ab60177c990a5305e Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Thu, 22 Oct 2026 23:15:54 +0900 Subject: [PATCH 16/65] =?UTF-8?q?eyecam:=20=E8=99=B9=E5=BD=A9=E3=81=AE?= =?UTF-8?q?=E5=8D=8A=E5=BE=84=E3=81=8C=E7=9E=B3=E3=81=AB=E5=AF=BE=E3=81=97?= =?UTF-8?q?=E3=81=A6=E5=A4=A7=E3=81=8D=E3=81=99=E3=81=8E=E3=82=8B=E3=81=8B?= =?UTF-8?q?=E3=81=B6=E3=82=8A=E3=81=A7=E3=81=AF=E3=80=81=E8=99=B9=E5=BD=A9?= =?UTF-8?q?=E3=81=AE=E7=B8=81=E3=82=92=E3=80=8C=E8=99=B9=E5=BD=A9=E3=81=8B?= =?UTF-8?q?=E3=82=89=E5=87=BA=E3=81=A6=E6=9C=80=E5=88=9D=E3=81=AE=E6=98=8E?= =?UTF-8?q?=E3=82=8B=E3=81=95=E3=81=AE=E7=AB=8B=E3=81=A1=E4=B8=8A=E3=81=8C?= =?UTF-8?q?=E3=82=8A=E3=80=8D=E3=81=A7=E6=B8=AC=E3=82=8B=EF=BC=88issue=20#?= =?UTF-8?q?38=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同じ人の右目は、校正の虹彩の半径が 88 px(左目 40.8、瞳は左目の 1.2 倍)になり、iris box とまぶたの探す帯が倍になって、閉じの「box が開いた目の 1.3 倍」が効きにくくなっていた。鼻側の光線が、強膜の向こうのもっと明るい縁(飽和していないインサートの帯など)を一番強い立ち上がりとして取ったと考えられる。 - 虹彩の半径の長い中央値が、開いた瞳の半径の中央値の 4 倍を超えたら、そのかぶりのあいだ、鼻側の光線は「一番強い立ち上がり」ではなく「その半分以上の強さで、途中で勾配が半分未満に下がって分かれている、手前の立ち上がり」を虹彩の縁にし、半径の中央値を学び直す - 開発者の校正の録画 10 本では、この比はいちばん大きくて 3.3(中央値 2.2)なので、開発者の映像では一度も切り替わらない。同じ人の校正は左目 3.0、右目 5.4 Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/src/feat.rs | 74 +++++++++++++++++++++++++++++++++++++++- tools/eyecam/src/live.rs | 59 ++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/tools/eyecam/src/feat.rs b/tools/eyecam/src/feat.rs index 4de87a4..8586e21 100644 --- a/tools/eyecam/src/feat.rs +++ b/tools/eyecam/src/feat.rs @@ -39,6 +39,14 @@ const MAX_CANDIDATES: usize = 3; const EDGE_LEFT_SHARE: f64 = 0.4; /// The second search without the prior only looks at blobs at least this far (px) from it. const RETRY_MIN_JUMP: f64 = 40.0; +/// The iris radius's rays toward large x take the strongest dark -> bright rise. Where something brighter than the +/// sclera lies beyond it (a prescription insert's glare band that is not saturated, so `glare_columns` does not see +/// it), that edge can be the stronger one and the radius comes out far too large: issue 38's right eye was calibrated +/// at R 88 px (left 40.8, its pupil 1.2 x the left one's), which doubles the iris box and the lid bands. With +/// `Extractor::iris_first` an earlier rise at least IRIS_FIRST_SHARE as strong, the gradient falling below IRIS_VALLEY +/// of it in between, is taken as the limbus instead. +const IRIS_FIRST_SHARE: f64 = 0.5; +const IRIS_VALLEY: f64 = 0.5; /// A pupil from the extended search (`Search::find_x_min`) must have its small-x (temporal) edge left of `x_min` + /// this many px: one further in is for the search inside `x_min` to find, and is not taken from the extended one (the /// same distance as `live::EDGE_LOST_PX`). @@ -523,6 +531,9 @@ pub struct Extractor { lid_r: f64, /// The grey steps' scale for this wear (live sets it from the column means; as built by default). pub levels: Levels, + /// The iris radius takes the first rise out of the iris toward large x, not the strongest (IRIS_FIRST_SHARE; live + /// sets it for a wear whose radius is implausible for its pupil). + pub iris_first: bool, } impl Default for Extractor { @@ -532,6 +543,7 @@ impl Default for Extractor { search: Search::LEGACY, opf_x0: OPF_X0, lid_r: f64::NAN, + iris_first: false, levels: Levels::AS_BUILT, op: Vec::new(), opf: Vec::new(), @@ -923,9 +935,25 @@ impl Extractor { k = i; } } - if gg[k] < self.levels.step(if large_x { 12.0 } else { 9.0 }) { + let th = self.levels.step(if large_x { 12.0 } else { 9.0 }); + if gg[k] < th { continue; } + // Toward large x the limbus is the first rise out of the iris: an earlier rise of its own (the gradient + // falls back below IRIS_VALLEY of it before the strongest) at least IRIS_FIRST_SHARE as strong is the + // limbus, and the strongest one an edge beyond the sclera (IRIS_FIRST_SHARE). + if self.iris_first + && large_x + && let Some(i) = (3..k).find(|&i| { + gg[i] >= th + && gg[i] >= IRIS_FIRST_SHARE * gg[k] + && gg[i] >= gg[i - 1] + && gg[i] >= gg[i + 1] + && gg[i + 1..k].iter().any(|&g| g < IRIS_VALLEY * gg[i]) + }) + { + k = i; + } let r = rok[k + 1] + 0.375; let v = (d.0 * r, d.1 * r); let (ra, rb) = (v.0 * ua.0 + v.1 * ua.1, v.0 * ub.0 + v.1 * ub.1); @@ -1364,6 +1392,50 @@ mod tests { let (f, _) = ex.extract(&eye_with_step_r(180, 12.0), 347.0, None, Some(55.0), None); assert!(!f.upper_edge && (f.upper_skin_y - 180.0).abs() < 3.0, "{f:?}"); } + + /// Pupil (12, radius 18) at (230, 200) in an iris (70, radius 45) on sclera (120), a ring of brighter iris (85) at + /// radius 28..34, the lens edge band from x 350, and from x `band` on a band of `level` (not saturated). + fn eye_with_band(band: usize, level: u8) -> Vec { + let mut img = vec![120u8; W * H]; + for y in 0..H { + for x in 0..W { + let d = (x as f64 - 230.0).hypot(y as f64 - 200.0); + img[y * W + x] = if x >= 350 { + 4 + } else if x >= band { + level + } else if d < 18.0 { + 12 + } else if (28.0..34.0).contains(&d) { + 85 + } else if d < 45.0 { + 70 + } else { + 120 + }; + } + } + img + } + + #[test] + fn the_iris_radius_is_the_first_rise_out_of_the_iris_not_a_brighter_band_beyond() { + // As built the strongest rise is taken: the band's edge. + let mut ex = Extractor::default(); + let (f, _) = ex.extract(&eye_with_band(305, 200), 347.0, None, None, None); + assert!(f.ok_pupil && (f.iris_r - 75.0).abs() < 3.0, "{f:?}"); + let mut ex = Extractor { iris_first: true, ..Extractor::default() }; + // No band: the limbus, not the fainter ring inside the iris. + let (f, _) = ex.extract(&eye_with_band(400, 0), 347.0, None, None, None); + assert!(f.ok_pupil && (f.iris_r - 45.0).abs() < 2.5, "{f:?}"); + // A band 75 px out, brighter than the sclera: its edge is the strongest rise on the rays toward it, but the + // limbus comes first. + let (f, _) = ex.extract(&eye_with_band(305, 200), 347.0, None, None, None); + assert!(f.ok_pupil && (f.iris_r - 45.0).abs() < 2.5, "{f:?}"); + // With the band right at the limbus there is one rise, the limbus's. + let (f, _) = ex.extract(&eye_with_band(275, 200), 347.0, None, None, None); + assert!(f.ok_pupil && (f.iris_r - 45.0).abs() < 2.5, "{f:?}"); + } #[test] fn the_higher_band_finds_a_widened_lid_above_the_usual_one() { let mut ex = Extractor::default(); diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index c8fed4b..e192521 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -119,6 +119,10 @@ const EXT_TRACK_S: f64 = 1.0; const EXT_TRACK_PX: f64 = 40.0; const EXT_A_PX: (f64, f64) = (8.0, 45.0); const EXT_VALVE_OPEN: f64 = 0.6; +/// The largest iris radius, in semi-axes of the open pupil, the iris fit is trusted with (`EyeEngine::iris_first`). +/// The developer's 10 recorded calibrations: at most 3.3 (median 2.2); issue 38's wear calibration, the left eye 3.0 and +/// the right eye 5.4 (R 88 px with a pupil 1.2 x the left one's). +const IRIS_PUPIL_MAX: f64 = 4.0; /// The lower lid hardly moves in a blink: a lower line found without the pupil more than this many iris radii below /// the last one seen with it is something else (`EyeEngine::last_lower`). Issue 38's wear calibration (a prescription /// insert): the left eye's lower line went 0.98 r (median) further down on the closed eye, so the closed eye read 0.95 r @@ -545,6 +549,9 @@ pub struct EyeEngine { bx: Win, pd: Win, nopupil: u32, + /// This wear's iris radius was found implausibly large for its pupil (IRIS_PUPIL_MAX): the iris fit takes the first + /// rise out of the iris from then on (`feat::Extractor::iris_first`). + iris_first: bool, /// The lower lid line (px) of the last frame with the pupil and a lower line, used on frames whose lower line was /// pushed against its band's far edge or, without the pupil, lies too far below it (LOWER_DROP_MAX): the lower lid /// hardly moves in a blink. @@ -776,6 +783,7 @@ impl Default for EyeEngine { bx: Win::new(), pd: Win::new(), nopupil: 0, + iris_first: false, last_lower: f64::NAN, grown: 0, box_open: LongMedian::new(60.0), @@ -975,6 +983,17 @@ impl EyeEngine { let search_x_min = self.find_x_min; self.ex.search = feat::Search::live_ext(self.x_min, search_x_min, self.ext_check(t, valve_open)); self.ex.levels = self.levels; + // An iris radius over IRIS_PUPIL_MAX x the open pupil's semi-axis is no limbus: for the rest of the wear the iris + // fit takes the first rise out of the iris, and the radius is learnt again from its fits. + if !self.iris_first + && self.r_est.len() >= 10 + && self.a_open.len() >= 30 + && self.r_est.value > IRIS_PUPIL_MAX * self.a_open.value + { + self.iris_first = true; + self.r_est = LongMedian::new(30.0); + } + self.ex.iris_first = self.iris_first; let r_ready = self.r_est.len() >= 10; let r_ref = r_ready.then_some(self.r_est.value); let no_prior = self.prev.is_none(); @@ -3756,6 +3775,46 @@ mod tests { (any, last.unwrap()) } + #[test] + fn an_iris_radius_far_too_large_for_the_pupil_switches_the_wear_to_the_first_rise() { + // A pupil of radius 12 in an iris of radius 40 (60 on sclera 120), and a band of 200 from x 330: the band's edge + // is the strongest rise toward large x, 80 px out. + let mut img = vec![120u8; vision::W * vision::H]; + for y in 0..vision::H { + for x in 0..vision::W { + let d = (x as f64 - 250.0).hypot(y as f64 - 200.0); + img[y * vision::W + x] = if x >= 350 { + 4 + } else if x >= 330 { + 200 + } else if d < 12.0 { + 12 + } else if d < 40.0 { + 60 + } else { + 120 + }; + } + } + let p = Params::default(); + let mut e = EyeEngine::default(); + let (mut switched_at, mut before) = (None, f64::NAN); + for k in 0..200 { + let o = e.process(&img, f64::NAN, k as f64 / FPS, &p); + assert!(o.f.ok_pupil); + if !e.iris_first { + before = o.f.iris_r; + } else if switched_at.is_none() { + switched_at = Some(k); + assert!((before - 80.0).abs() < 4.0, "the fits before: {before}"); + } + } + assert!(switched_at.is_some_and(|k| k < 100), "{switched_at:?}"); + assert!((e.r_est.value - 40.0).abs() < 3.0, "learnt again from the first rise: {}", e.r_est.value); + // A plausible radius leaves the wear as built. + let (e, _, _) = seen_at((250.0, 200.0), Tune::default()); + assert!(!e.iris_first && !e.ex.iris_first); + } #[test] fn a_lower_line_at_the_far_edge_of_its_band_keeps_the_last_lower_lid() { From f166df4feaf46f724393765b99ac95a5b0267995 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Fri, 23 Oct 2026 01:59:22 +0900 Subject: [PATCH 17/65] =?UTF-8?q?eyecam:=20=E8=99=B9=E5=BD=A9=E3=81=8B?= =?UTF-8?q?=E3=82=89=E5=87=BA=E3=81=A6=E6=9C=80=E5=88=9D=E3=81=AE=E7=AB=8B?= =?UTF-8?q?=E3=81=A1=E4=B8=8A=E3=81=8C=E3=82=8A=E3=82=92=E5=8F=96=E3=82=8B?= =?UTF-8?q?=E5=BC=B7=E3=81=95=E3=81=AE=E4=B8=8B=E9=99=90=E3=82=92=200.5=20?= =?UTF-8?q?=E3=81=8B=E3=82=89=200.35=20=E3=81=AB=E3=81=99=E3=82=8B?= =?UTF-8?q?=EF=BC=88issue=20#38=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最初から切り替えた状態で録画 26 本を流した(映像を 25 px 上にずらし、x 300 から明るさ 235 の鼻側の帯を足して、虹彩の半径を中央値 79 / 75 px に引っぱる腕)。 - 0.35: 半径 62 / 58 px(帯なし 61 / 54)、自由使用の閉じの取りこぼし 1.27 → 0.84 回 / 目・分、細め 0.81 → 0.87、見開き 0.72 → 0.80。誤った半目は 5.3% → 7.9% - 0.5: 半径 73 / 68 px、取りこぼし 1.06 - 元の録画では、最初から切り替えてもほぼ変わらない(取りこぼし 0.39 → 0.40)。切り替えそのものは元の録画では起きない(バイト一致) Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/src/feat.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/eyecam/src/feat.rs b/tools/eyecam/src/feat.rs index 8586e21..a2b2f18 100644 --- a/tools/eyecam/src/feat.rs +++ b/tools/eyecam/src/feat.rs @@ -45,7 +45,12 @@ const RETRY_MIN_JUMP: f64 = 40.0; /// at R 88 px (left 40.8, its pupil 1.2 x the left one's), which doubles the iris box and the lid bands. With /// `Extractor::iris_first` an earlier rise at least IRIS_FIRST_SHARE as strong, the gradient falling below IRIS_VALLEY /// of it in between, is taken as the limbus instead. -const IRIS_FIRST_SHARE: f64 = 0.5; +/// Replays with the first-rise fit on from the start (26 recordings, a picture shifted up 25 px with a nasal band of +/// 235 from x 300 that pulls the fit out to a median of 79 / 75 px, against 61 / 54 without): at 0.35 the radius came +/// back to 62 / 58 px, closes missed in free use 1.27 -> 0.84 a minute, squint 0.81 -> 0.87, widen 0.72 -> 0.80, but +/// false half-shut 5.3% -> 7.9%; at 0.5, 73 / 68 px and 1.06 a minute. On the original recordings it changed little +/// (closes missed 0.39 -> 0.40 a minute), and the switch itself never happens there. +const IRIS_FIRST_SHARE: f64 = 0.35; const IRIS_VALLEY: f64 = 0.5; /// A pupil from the extended search (`Search::find_x_min`) must have its small-x (temporal) edge left of `x_min` + /// this many px: one further in is for the search inside `x_min` to find, and is not taken from the extended one (the From e87b0583539a5c289bc370fe04eaeb61f652c467 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Fri, 23 Oct 2026 02:41:28 +0900 Subject: [PATCH 18/65] =?UTF-8?q?eyecam:=20=E8=99=B9=E5=BD=A9=E3=81=AE?= =?UTF-8?q?=E7=B8=81=E3=81=AE=E6=B8=AC=E3=82=8A=E6=96=B9=E3=82=92=E5=88=87?= =?UTF-8?q?=E3=82=8A=E6=9B=BF=E3=81=88=E3=81=9F=E3=81=A8=E3=81=8D=E3=81=AF?= =?UTF-8?q?=E3=80=81=E9=96=8B=E3=81=84=E3=81=9F=E7=9B=AE=E3=81=AE=20iris?= =?UTF-8?q?=20box=20=E3=81=AE=E6=98=8E=E3=82=8B=E3=81=95=E3=82=82=E8=A6=9A?= =?UTF-8?q?=E3=81=88=E7=9B=B4=E3=81=99=EF=BC=88=E3=83=81=E3=82=A7=E3=83=83?= =?UTF-8?q?=E3=82=AF=E6=8B=85=E5=BD=93=E3=81=AE=E6=8C=87=E6=91=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切り替えで虹彩の半径を覚え直す(例: 88 → 40 px)と、半径で大きさが決まる iris box は小さく暗くなるのに、開いた目の box の明るさ(60 秒の中央値)は前の大きさのまま残り、閉じの「開いた目の 1.3 倍」に最大 60 秒届きにくくなっていた。切り替えのときに一緒に空にし、30 サンプル(約 0.7 秒)で覚え直す。 録画 26 本の再生(映像を 25 px 上にずらし、鼻側に明るさ 235 の帯を足して虹彩の半径を引っぱる腕。切り替えは 4 倍の条件のまま): - 覚え直しあり / なしで、自由使用の閉じの取りこぼし 1.08 / 1.08 回 / 目・分、0.2 秒未満のまばたき 0.834 / 0.834、誤った半目 6.4% / 6.5%、開いた目で閉じ 2.6% / 2.8% - 合図の細め 0.824 / 0.793(1 本の録画で、閉じと読まれていた細めが細めに戻る)、合図の閉じ 0.940 / 0.956(1 本の録画の右目で、切り替えが閉じの段の途中で起き、覚え直すまでの 0.7 秒ほど閉じが出ない) - 元の録画では切り替えが起きないので、何も変わらない(26 本と校正 10 本がバイト一致) Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/src/live.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index e192521..ea1ff02 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -984,7 +984,9 @@ impl EyeEngine { self.ex.search = feat::Search::live_ext(self.x_min, search_x_min, self.ext_check(t, valve_open)); self.ex.levels = self.levels; // An iris radius over IRIS_PUPIL_MAX x the open pupil's semi-axis is no limbus: for the rest of the wear the iris - // fit takes the first rise out of the iris, and the radius is learnt again from its fits. + // fit takes the first rise out of the iris, and the radius is learnt again from its fits. So is the open eye's iris + // box level: the box is sized by the radius, and one learnt at the old radius (60 s) would hold the closed eye's + // 1.3 x out of reach of the smaller, darker box. if !self.iris_first && self.r_est.len() >= 10 && self.a_open.len() >= 30 @@ -992,6 +994,7 @@ impl EyeEngine { { self.iris_first = true; self.r_est = LongMedian::new(30.0); + self.box_open = LongMedian::new(60.0); } self.ex.iris_first = self.iris_first; let r_ready = self.r_est.len() >= 10; @@ -3807,6 +3810,7 @@ mod tests { } else if switched_at.is_none() { switched_at = Some(k); assert!((before - 80.0).abs() < 4.0, "the fits before: {before}"); + assert!(e.box_open.len() <= 1, "the open box level is learnt again: {}", e.box_open.len()); } } assert!(switched_at.is_some_and(|k| k < 100), "{switched_at:?}"); From cfeaf67cd2c713687732aa964bd62e55f3f624b2 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Fri, 23 Oct 2026 02:42:14 +0900 Subject: [PATCH 19/65] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B1=A5=E6=AD=B4:=20?= =?UTF-8?q?=E5=BA=A6=E4=BB=98=E3=81=8D=E3=82=A4=E3=83=B3=E3=82=B5=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=81=A7=E9=96=89=E3=81=98=E3=81=9F=E7=9B=AE=E3=81=8C?= =?UTF-8?q?=E5=B0=91=E3=81=97=E9=96=8B=E3=81=84=E3=81=A6=E8=AA=AD=E3=81=BE?= =?UTF-8?q?=E3=82=8C=E3=82=8B=E3=83=BB=E8=99=B9=E5=BD=A9=E3=81=8C=E5=A4=A7?= =?UTF-8?q?=E3=81=8D=E3=81=8F=E6=B8=AC=E3=82=89=E3=82=8C=E3=82=8B=E4=BB=B6?= =?UTF-8?q?=E3=81=AE=E7=9B=B4=E3=81=97=E3=82=92=200.8.2-beta.1=20=E3=81=AB?= =?UTF-8?q?=E6=9B=B8=E3=81=8F=EF=BC=88issue=20#38=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.ja.md | 1 + CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 009ecf8..6f6625d 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -9,6 +9,7 @@ SteamOS が開いた片目を閉じていると読み続けても、視線が止 - eyecam: 急に普段の 1.3 倍より大きく写った瞳(耳側を大きく見るとカメラの方を向き、暗くなると瞳が開く)は、まばたきの直後に大きさの確認で落とされ、小さく戻るまで見失ったままでした(ある録画では 7.4 秒)。今は、そういう瞳が開いた目の瞳と同じくらい暗く欠けがなく、平たくもなっていなくて、0.3 秒見え続けたら採り、普段の大きさもそこから測り直します。録画 26 本で流し直すと、開いた目のフレームで瞳が見つかったのは 88.8 % から 89.0 %、閉じの取りこぼしは 1 分あたり 0.402 回から 0.386 回で、閉じ・細め・見開きは変わりませんでした - 目のカメラを使ったまぶた(SteamOS 0.4.x): SteamOS の目のトラッキングは開いた目をカメラよりかなり低く読むことが多く、両者がそろわないまま、まばたきのたびにカメラの遅れの分だけまぶたの「疑い」が積み重なって、最後はカメラがずっとまぶたを動かすようになり、カメラが見逃したまばたきは開いたまま送られていました。今は、SteamOS が目を 1 秒開いていると読んだら疑いをやり直します。流し直しでは、右目がカメラのまぶたになっていた時間が 0.67 時間のうち 130 秒から 111 秒になり、まぶたのサンプルの 0.3〜0.6 % が変わりました - eyecam、ユーザー校正: 細めは、そのかぶりの「開いた〜閉じた」の幅に対する割合で保存されていたのに、使うときは普段の幅に掛けていました。この幅はかぶるたびに 0.6〜1.5 倍違うので、校正した細めの深さが 0.65〜1.5 倍ずれていました。今はユーザー校正で細めの深さそのものも保存し(各目のユーザーの値の `sq_depth`)、使うときもそれを、校正と同じ上限・下限の中で使います。案内に沿った「普通」の段で開いた目が細めと読まれたのは 2.4 % から 0.8 % になり、いちばん細めたときは少し低めに読まれます(0.9 以上が 53 % から 35 %)。新しくユーザー校正をしたあとから効き、前の校正は今まで通り動きます。細めが浅すぎると感じたら、ユーザー校正をやり直してください。前の版に戻してそこで校正を保存すると `sq_depth` が消えるので、更新したあとにユーザー校正をやり直してください +- eyecam、度付きインサート: 目のカメラの前にインサートがあると、閉じた目が少し開いていると読まれたり、虹彩が大きすぎると測られて閉じが見えにくくなったりしていました。閉じた目で見つけた下まぶたの線が、開いていたときより虹彩の半径の 0.9 倍以上下にあるときや、探す範囲の下端に張り付いているときは使わず、最後に瞳が見えていたときの線を使います(下まぶたはまばたきでほとんど動かないため)。瞳が欠けずに見えているのに上まぶたの線が瞳の中心より下にあるときも、その線は使いません。そういうかぶりの校正のサンプルで測り直すと、左目の「開いた〜閉じた」の幅は 0.30 から 1.35 になりました(右目は 1.31)。かぶりの虹彩の半径が開いた瞳の 4 倍を超えたら、虹彩の縁をいちばん強い明るさの変わり目ではなく、虹彩から出て最初の立ち上がりで測り、半径と開いた目の iris box の明るさを覚え直します。半径を 61 / 54 px から 79 / 75 px に引っぱる帯を足した録画 26 本で流し直すと、半径は 72 / 67 px、閉じの取りこぼしは 1 分あたり 1.27 回から 1.08 回、0.2 秒未満のまばたきは 80 % から 83 % 取れるようになり、そのかわり開いた目が半目と読まれたのは 5.3 % から 6.4 % になりました。元の録画では半径の切り替えは起きず、案内に沿った閉じが取れたのは 97.3 % から 98.2 % で、ほかは変わりませんでした。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください ## 0.8.1 (2026-10-21) diff --git a/CHANGELOG.md b/CHANGELOG.md index feadbb4..5e4b2a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The gaze no longer stays frozen when SteamOS keeps reading one open eye as close - eyecam: a pupil that suddenly looks more than 1.3 times as large as usual (looking far toward your ear turns it toward the camera; a darker world dilates it) was dropped by the size check right after a blink, and stayed lost until it shrank again (7.4 s in one recording). Now, when such a pupil is as dark and as whole as the open one and no flatter, and is seen for 0.3 s, it is taken and the usual size starts over from it. Replayed on 26 recordings: the pupil found in 89.0 % of open-eye frames instead of 88.8 %, closes missed 0.386 instead of 0.402 a minute, closing, squint and widening the same. - Eyelids with the eye cameras on SteamOS 0.4.x: its eye tracker often reads an open eye well below the camera, so the two never agreed and each blink's camera lag added to the eyelid's "doubt" until the camera drove the eyelid for good, and blinks the camera missed went out open. The doubt now starts over once the eye tracker has read the eye open for 1 s. In replays the right eye's time on the camera eyelid went from 130 s to 111 s in 0.67 h; 0.3-0.6 % of eyelid samples changed. - eyecam, user calibration: the squint was saved as a fraction of that wear's open-to-closed gap but used against the usual gap, which differs by 0.6 to 1.5 times from wear to wear, so the calibrated squint came out 0.65 to 1.5 times as deep. The user calibration now also saves the squint's depth (`sq_depth` in each eye's user values), and live uses it, kept within the same limits as the calibration's. Open eyes read as a squint went from 2.4 % to 0.8 % in the guided normal step; a full squint reads a little lower (0.9 or more 35 % of the time instead of 53 %). This takes effect after a new user calibration; older calibrations work as before. If your squint now reads too shallow, run the user calibration again. Going back to an older version and saving a calibration there drops `sq_depth`, so run the user calibration again after updating. +- eyecam, prescription inserts: with an insert in front of the eye cameras, a closed eye could read partly open, and an iris fit far too large made closes harder to see. A lower lid line found on a closed eye 0.9 iris radii or more below where it was with the eye open, or against the bottom of its search band, is no longer taken: the last one seen with the pupil is used instead (the lower lid hardly moves in a blink), and an upper lid line below the centre of a pupil seen whole is dropped. Re-fitting one such wear calibration's samples, the left eye's open-to-closed gap goes from 0.30 to 1.35 (its right eye: 1.31). When a wear's iris radius comes out over 4 times the open pupil's, the iris edge is taken at the first rise out of the iris instead of the strongest edge, and the radius and the open eye's iris-box level are learnt again. Replayed on 26 recordings with a band added that pulls the radius from 61 / 54 px to 79 / 75 px: 72 / 67 px, closes missed 1.08 instead of 1.27 a minute, blinks under 0.2 s caught 83 % instead of 80 %, but open eyes read half-shut 6.4 % of the time instead of 5.3 %. On the original recordings the radius switch never happens; guided closes were caught 98.2 % of the time instead of 97.3 %, and everything else stayed the same. If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. ## 0.8.1 (2026-10-21) From 11cef85fa503806fc5823f5881b653584ad30bc8 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Fri, 23 Oct 2026 02:45:15 +0900 Subject: [PATCH 20/65] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B1=A5=E6=AD=B4:=20#?= =?UTF-8?q?38=20=E3=81=AE=E8=A1=8C=E3=81=AE=E3=80=8C=E3=81=BB=E3=81=8B?= =?UTF-8?q?=E3=81=AF=E5=A4=89=E3=82=8F=E3=82=89=E3=81=AA=E3=81=84=E3=80=8D?= =?UTF-8?q?=E3=82=92=E3=80=81=E5=8D=8A=E7=9B=AE=205.3=20=E2=86=92=205.4=20?= =?UTF-8?q?%=20=E3=82=92=E6=B7=BB=E3=81=88=E3=81=9F=E3=80=8C=E3=81=BB?= =?UTF-8?q?=E3=81=BC=E5=90=8C=E3=81=98=E3=80=8D=E3=81=AB=E5=BC=B1=E3=82=81?= =?UTF-8?q?=E3=82=8B=EF=BC=88=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E6=8B=85?= =?UTF-8?q?=E5=BD=93=E3=81=AE=E6=8C=87=E6=91=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.ja.md | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 6f6625d..e919d27 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -9,7 +9,7 @@ SteamOS が開いた片目を閉じていると読み続けても、視線が止 - eyecam: 急に普段の 1.3 倍より大きく写った瞳(耳側を大きく見るとカメラの方を向き、暗くなると瞳が開く)は、まばたきの直後に大きさの確認で落とされ、小さく戻るまで見失ったままでした(ある録画では 7.4 秒)。今は、そういう瞳が開いた目の瞳と同じくらい暗く欠けがなく、平たくもなっていなくて、0.3 秒見え続けたら採り、普段の大きさもそこから測り直します。録画 26 本で流し直すと、開いた目のフレームで瞳が見つかったのは 88.8 % から 89.0 %、閉じの取りこぼしは 1 分あたり 0.402 回から 0.386 回で、閉じ・細め・見開きは変わりませんでした - 目のカメラを使ったまぶた(SteamOS 0.4.x): SteamOS の目のトラッキングは開いた目をカメラよりかなり低く読むことが多く、両者がそろわないまま、まばたきのたびにカメラの遅れの分だけまぶたの「疑い」が積み重なって、最後はカメラがずっとまぶたを動かすようになり、カメラが見逃したまばたきは開いたまま送られていました。今は、SteamOS が目を 1 秒開いていると読んだら疑いをやり直します。流し直しでは、右目がカメラのまぶたになっていた時間が 0.67 時間のうち 130 秒から 111 秒になり、まぶたのサンプルの 0.3〜0.6 % が変わりました - eyecam、ユーザー校正: 細めは、そのかぶりの「開いた〜閉じた」の幅に対する割合で保存されていたのに、使うときは普段の幅に掛けていました。この幅はかぶるたびに 0.6〜1.5 倍違うので、校正した細めの深さが 0.65〜1.5 倍ずれていました。今はユーザー校正で細めの深さそのものも保存し(各目のユーザーの値の `sq_depth`)、使うときもそれを、校正と同じ上限・下限の中で使います。案内に沿った「普通」の段で開いた目が細めと読まれたのは 2.4 % から 0.8 % になり、いちばん細めたときは少し低めに読まれます(0.9 以上が 53 % から 35 %)。新しくユーザー校正をしたあとから効き、前の校正は今まで通り動きます。細めが浅すぎると感じたら、ユーザー校正をやり直してください。前の版に戻してそこで校正を保存すると `sq_depth` が消えるので、更新したあとにユーザー校正をやり直してください -- eyecam、度付きインサート: 目のカメラの前にインサートがあると、閉じた目が少し開いていると読まれたり、虹彩が大きすぎると測られて閉じが見えにくくなったりしていました。閉じた目で見つけた下まぶたの線が、開いていたときより虹彩の半径の 0.9 倍以上下にあるときや、探す範囲の下端に張り付いているときは使わず、最後に瞳が見えていたときの線を使います(下まぶたはまばたきでほとんど動かないため)。瞳が欠けずに見えているのに上まぶたの線が瞳の中心より下にあるときも、その線は使いません。そういうかぶりの校正のサンプルで測り直すと、左目の「開いた〜閉じた」の幅は 0.30 から 1.35 になりました(右目は 1.31)。かぶりの虹彩の半径が開いた瞳の 4 倍を超えたら、虹彩の縁をいちばん強い明るさの変わり目ではなく、虹彩から出て最初の立ち上がりで測り、半径と開いた目の iris box の明るさを覚え直します。半径を 61 / 54 px から 79 / 75 px に引っぱる帯を足した録画 26 本で流し直すと、半径は 72 / 67 px、閉じの取りこぼしは 1 分あたり 1.27 回から 1.08 回、0.2 秒未満のまばたきは 80 % から 83 % 取れるようになり、そのかわり開いた目が半目と読まれたのは 5.3 % から 6.4 % になりました。元の録画では半径の切り替えは起きず、案内に沿った閉じが取れたのは 97.3 % から 98.2 % で、ほかは変わりませんでした。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください +- eyecam、度付きインサート: 目のカメラの前にインサートがあると、閉じた目が少し開いていると読まれたり、虹彩が大きすぎると測られて閉じが見えにくくなったりしていました。閉じた目で見つけた下まぶたの線が、開いていたときより虹彩の半径の 0.9 倍以上下にあるときや、探す範囲の下端に張り付いているときは使わず、最後に瞳が見えていたときの線を使います(下まぶたはまばたきでほとんど動かないため)。瞳が欠けずに見えているのに上まぶたの線が瞳の中心より下にあるときも、その線は使いません。そういうかぶりの校正のサンプルで測り直すと、左目の「開いた〜閉じた」の幅は 0.30 から 1.35 になりました(右目は 1.31)。かぶりの虹彩の半径が開いた瞳の 4 倍を超えたら、虹彩の縁をいちばん強い明るさの変わり目ではなく、虹彩から出て最初の立ち上がりで測り、半径と開いた目の iris box の明るさを覚え直します。半径を 61 / 54 px から 79 / 75 px に引っぱる帯を足した録画 26 本で流し直すと、半径は 72 / 67 px、閉じの取りこぼしは 1 分あたり 1.27 回から 1.08 回、0.2 秒未満のまばたきは 80 % から 83 % 取れるようになり、そのかわり開いた目が半目と読まれたのは 5.3 % から 6.4 % になりました。元の録画では半径の切り替えは起きず、案内に沿った閉じが取れたのは 97.3 % から 98.2 % で、ほかはほぼ同じでした(開いた目が半目と読まれたのは 5.3 % から 5.4 %)。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください ## 0.8.1 (2026-10-21) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4b2a4..d04392a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The gaze no longer stays frozen when SteamOS keeps reading one open eye as close - eyecam: a pupil that suddenly looks more than 1.3 times as large as usual (looking far toward your ear turns it toward the camera; a darker world dilates it) was dropped by the size check right after a blink, and stayed lost until it shrank again (7.4 s in one recording). Now, when such a pupil is as dark and as whole as the open one and no flatter, and is seen for 0.3 s, it is taken and the usual size starts over from it. Replayed on 26 recordings: the pupil found in 89.0 % of open-eye frames instead of 88.8 %, closes missed 0.386 instead of 0.402 a minute, closing, squint and widening the same. - Eyelids with the eye cameras on SteamOS 0.4.x: its eye tracker often reads an open eye well below the camera, so the two never agreed and each blink's camera lag added to the eyelid's "doubt" until the camera drove the eyelid for good, and blinks the camera missed went out open. The doubt now starts over once the eye tracker has read the eye open for 1 s. In replays the right eye's time on the camera eyelid went from 130 s to 111 s in 0.67 h; 0.3-0.6 % of eyelid samples changed. - eyecam, user calibration: the squint was saved as a fraction of that wear's open-to-closed gap but used against the usual gap, which differs by 0.6 to 1.5 times from wear to wear, so the calibrated squint came out 0.65 to 1.5 times as deep. The user calibration now also saves the squint's depth (`sq_depth` in each eye's user values), and live uses it, kept within the same limits as the calibration's. Open eyes read as a squint went from 2.4 % to 0.8 % in the guided normal step; a full squint reads a little lower (0.9 or more 35 % of the time instead of 53 %). This takes effect after a new user calibration; older calibrations work as before. If your squint now reads too shallow, run the user calibration again. Going back to an older version and saving a calibration there drops `sq_depth`, so run the user calibration again after updating. -- eyecam, prescription inserts: with an insert in front of the eye cameras, a closed eye could read partly open, and an iris fit far too large made closes harder to see. A lower lid line found on a closed eye 0.9 iris radii or more below where it was with the eye open, or against the bottom of its search band, is no longer taken: the last one seen with the pupil is used instead (the lower lid hardly moves in a blink), and an upper lid line below the centre of a pupil seen whole is dropped. Re-fitting one such wear calibration's samples, the left eye's open-to-closed gap goes from 0.30 to 1.35 (its right eye: 1.31). When a wear's iris radius comes out over 4 times the open pupil's, the iris edge is taken at the first rise out of the iris instead of the strongest edge, and the radius and the open eye's iris-box level are learnt again. Replayed on 26 recordings with a band added that pulls the radius from 61 / 54 px to 79 / 75 px: 72 / 67 px, closes missed 1.08 instead of 1.27 a minute, blinks under 0.2 s caught 83 % instead of 80 %, but open eyes read half-shut 6.4 % of the time instead of 5.3 %. On the original recordings the radius switch never happens; guided closes were caught 98.2 % of the time instead of 97.3 %, and everything else stayed the same. If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. +- eyecam, prescription inserts: with an insert in front of the eye cameras, a closed eye could read partly open, and an iris fit far too large made closes harder to see. A lower lid line found on a closed eye 0.9 iris radii or more below where it was with the eye open, or against the bottom of its search band, is no longer taken: the last one seen with the pupil is used instead (the lower lid hardly moves in a blink), and an upper lid line below the centre of a pupil seen whole is dropped. Re-fitting one such wear calibration's samples, the left eye's open-to-closed gap goes from 0.30 to 1.35 (its right eye: 1.31). When a wear's iris radius comes out over 4 times the open pupil's, the iris edge is taken at the first rise out of the iris instead of the strongest edge, and the radius and the open eye's iris-box level are learnt again. Replayed on 26 recordings with a band added that pulls the radius from 61 / 54 px to 79 / 75 px: 72 / 67 px, closes missed 1.08 instead of 1.27 a minute, blinks under 0.2 s caught 83 % instead of 80 %, but open eyes read half-shut 6.4 % of the time instead of 5.3 %. On the original recordings the radius switch never happens; guided closes were caught 98.2 % of the time instead of 97.3 %, and everything else stayed about the same (open eyes read half-shut 5.4 % of the time instead of 5.3 %). If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. ## 0.8.1 (2026-10-21) From 9ff2eb3d24751b71444e0f82ec45f17be943a15e Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Fri, 23 Oct 2026 20:58:28 +0900 Subject: [PATCH 21/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E3=80=8C?= =?UTF-8?q?=E7=9B=AE=E3=81=94=E3=81=A8=E3=81=AE=E8=A6=96=E7=B7=9A=E3=80=8D?= =?UTF-8?q?=E3=82=92=205=20%=20=E5=88=BB=E3=81=BF=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0〜100 % を 10 % 刻みから 5 % 刻みにして、5 % のような小さな混ぜ方も選べるようにする。設定ファイルの値の範囲と既定(0)は変わらない。 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.ja.md | 2 +- CHANGELOG.md | 2 +- panel/README.md | 2 +- panel/src/config.cpp | 2 +- panel/tests/model_test.cpp | 4 ++-- panel/tests/panel_test.cpp | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index e919d27..eda028b 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -5,7 +5,7 @@ SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。 - 視線: どちらかの目の開き具合が `gaze_hold_below`(0.5)より小さい間は、まばたきのために視線を止めています。SteamOS は開いた片目を 0.06 くらいと何分も何時間も読み続けることがあり(度付きインサートのときなど)、そのあいだ、まぶたと瞳孔は動くのに視線だけ止まっていました。今は、片目だけが線より下のときは 1 秒まで(ウインク)止め、それより長いとその目の読みは誤りとみなして止めず、もう片方の目で両目を動かします。まばたきは今までどおり止め、閉じたと読まれ続けている目があっても、まばたきのたびにまた 1 秒止まることはありません。1 サンプルだけ開いたと読まれても、1 秒はやり直しになりません -- 新しい設定 `own_eye_gaze`(既定 0、0〜1。`--own-eye-gaze`)。視線 › 動く範囲の「左右の目を別々に動かす」の下の「目ごとの視線」(0〜100 %、10 % 刻み)です。「左右の目を別々に動かす」がオン(v2)のとき、目ごとの左右の視線を、2° 寄せた共通の視線(0 %、今まで通り)から、Frame が追った目ごとの左右の視線そのまま(100 %)のあいだで混ぜます。Frame の目ごとの左右の視線はぶれるので、上げるほどアバターの目がぴくつくことがあります。上下・まぶたなどはそのままで、閉じたと長く読まれている目(上)や視線が当てにならない目は、今までどおりもう片方の目の視線を使います。0 なら送るものは今までと同じです。「左右の目を別々に動かす」がオフのときと v1(もともと目ごとの視線をそのまま送ります)のときは薄く表示されます +- 新しい設定 `own_eye_gaze`(既定 0、0〜1。`--own-eye-gaze`)。視線 › 動く範囲の「左右の目を別々に動かす」の下の「目ごとの視線」(0〜100 %、5 % 刻み)です。「左右の目を別々に動かす」がオン(v2)のとき、目ごとの左右の視線を、2° 寄せた共通の視線(0 %、今まで通り)から、Frame が追った目ごとの左右の視線そのまま(100 %)のあいだで混ぜます。Frame の目ごとの左右の視線はぶれるので、上げるほどアバターの目がぴくつくことがあります。上下・まぶたなどはそのままで、閉じたと長く読まれている目(上)や視線が当てにならない目は、今までどおりもう片方の目の視線を使います。0 なら送るものは今までと同じです。「左右の目を別々に動かす」がオフのときと v1(もともと目ごとの視線をそのまま送ります)のときは薄く表示されます - eyecam: 急に普段の 1.3 倍より大きく写った瞳(耳側を大きく見るとカメラの方を向き、暗くなると瞳が開く)は、まばたきの直後に大きさの確認で落とされ、小さく戻るまで見失ったままでした(ある録画では 7.4 秒)。今は、そういう瞳が開いた目の瞳と同じくらい暗く欠けがなく、平たくもなっていなくて、0.3 秒見え続けたら採り、普段の大きさもそこから測り直します。録画 26 本で流し直すと、開いた目のフレームで瞳が見つかったのは 88.8 % から 89.0 %、閉じの取りこぼしは 1 分あたり 0.402 回から 0.386 回で、閉じ・細め・見開きは変わりませんでした - 目のカメラを使ったまぶた(SteamOS 0.4.x): SteamOS の目のトラッキングは開いた目をカメラよりかなり低く読むことが多く、両者がそろわないまま、まばたきのたびにカメラの遅れの分だけまぶたの「疑い」が積み重なって、最後はカメラがずっとまぶたを動かすようになり、カメラが見逃したまばたきは開いたまま送られていました。今は、SteamOS が目を 1 秒開いていると読んだら疑いをやり直します。流し直しでは、右目がカメラのまぶたになっていた時間が 0.67 時間のうち 130 秒から 111 秒になり、まぶたのサンプルの 0.3〜0.6 % が変わりました - eyecam、ユーザー校正: 細めは、そのかぶりの「開いた〜閉じた」の幅に対する割合で保存されていたのに、使うときは普段の幅に掛けていました。この幅はかぶるたびに 0.6〜1.5 倍違うので、校正した細めの深さが 0.65〜1.5 倍ずれていました。今はユーザー校正で細めの深さそのものも保存し(各目のユーザーの値の `sq_depth`)、使うときもそれを、校正と同じ上限・下限の中で使います。案内に沿った「普通」の段で開いた目が細めと読まれたのは 2.4 % から 0.8 % になり、いちばん細めたときは少し低めに読まれます(0.9 以上が 53 % から 35 %)。新しくユーザー校正をしたあとから効き、前の校正は今まで通り動きます。細めが浅すぎると感じたら、ユーザー校正をやり直してください。前の版に戻してそこで校正を保存すると `sq_depth` が消えるので、更新したあとにユーザー校正をやり直してください diff --git a/CHANGELOG.md b/CHANGELOG.md index d04392a..63b4f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The gaze no longer stays frozen when SteamOS keeps reading one open eye as close 日本語: SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。 - Gaze: the gaze is held while either eye's openness is below `gaze_hold_below` (0.5), for blinks. SteamOS can read one open eye at about 0.06 for minutes to hours (with prescription inserts, for example), and the gaze stood still all that time while the eyelids and pupils kept moving. Now one eye alone below the line holds the gaze for up to 1 s (a wink); longer, and that eye's reading is taken as wrong: it no longer holds the gaze, and the other eye moves both. Blinks are held as before, and an eye read closed for good is not held again for a second after each blink. A single sample read open doesn't start the second over. -- New setting `own_eye_gaze` (0 by default, 0 to 1; `--own-eye-gaze`), "Own gaze per eye" on Gaze › Reach under "Move eyes separately" (0-100 %, 10 % steps). With "Move eyes separately" on (v2), it blends each eye's sideways gaze from the shared one turned in by 2° (0 %, as before) to each eye's own sideways gaze as the Frame tracks it (100 %). The Frame's per-eye sideways gaze wobbles, so the avatar's eyes may twitch at higher values. Up/down, the eyelids and everything else stay as they are; an eye read closed for long (above) or with an unreliable gaze still takes the other's. At 0 everything sent is as before. It is greyed out while "Move eyes separately" is off, and in v1 (which already sends each eye's own gaze). +- New setting `own_eye_gaze` (0 by default, 0 to 1; `--own-eye-gaze`), "Own gaze per eye" on Gaze › Reach under "Move eyes separately" (0-100 %, 5 % steps). With "Move eyes separately" on (v2), it blends each eye's sideways gaze from the shared one turned in by 2° (0 %, as before) to each eye's own sideways gaze as the Frame tracks it (100 %). The Frame's per-eye sideways gaze wobbles, so the avatar's eyes may twitch at higher values. Up/down, the eyelids and everything else stay as they are; an eye read closed for long (above) or with an unreliable gaze still takes the other's. At 0 everything sent is as before. It is greyed out while "Move eyes separately" is off, and in v1 (which already sends each eye's own gaze). - eyecam: a pupil that suddenly looks more than 1.3 times as large as usual (looking far toward your ear turns it toward the camera; a darker world dilates it) was dropped by the size check right after a blink, and stayed lost until it shrank again (7.4 s in one recording). Now, when such a pupil is as dark and as whole as the open one and no flatter, and is seen for 0.3 s, it is taken and the usual size starts over from it. Replayed on 26 recordings: the pupil found in 89.0 % of open-eye frames instead of 88.8 %, closes missed 0.386 instead of 0.402 a minute, closing, squint and widening the same. - Eyelids with the eye cameras on SteamOS 0.4.x: its eye tracker often reads an open eye well below the camera, so the two never agreed and each blink's camera lag added to the eyelid's "doubt" until the camera drove the eyelid for good, and blinks the camera missed went out open. The doubt now starts over once the eye tracker has read the eye open for 1 s. In replays the right eye's time on the camera eyelid went from 130 s to 111 s in 0.67 h; 0.3-0.6 % of eyelid samples changed. - eyecam, user calibration: the squint was saved as a fraction of that wear's open-to-closed gap but used against the usual gap, which differs by 0.6 to 1.5 times from wear to wear, so the calibrated squint came out 0.65 to 1.5 times as deep. The user calibration now also saves the squint's depth (`sq_depth` in each eye's user values), and live uses it, kept within the same limits as the calibration's. Open eyes read as a squint went from 2.4 % to 0.8 % in the guided normal step; a full squint reads a little lower (0.9 or more 35 % of the time instead of 53 %). This takes effect after a new user calibration; older calibrations work as before. If your squint now reads too shallow, run the user calibration again. Going back to an older version and saving a calibration there drops `sq_depth`, so run the user calibration again after updating. diff --git a/panel/README.md b/panel/README.md index c82d2ed..dfb1590 100644 --- a/panel/README.md +++ b/panel/README.md @@ -27,7 +27,7 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ |---|---| | 基本 | 送信(送る / 止める)、送り先(3 枚のカード: VRChat に直接 / VRCFT(LiveLink)「おすすめ」 / VRCFT(FrameEyeOSC)「全部入り」。タグはカードの上の縁に。それぞれ 2 行で 同期・PC に何が要るか(`outputMarks[4][2]`。見開きの行は無い)。FrameEyeOSC の同期は日本語版が二重丸(`ICON_DOUBLE_RING`)、英語版が「✓ Synced, pupils too」。選んだカードは太い枠と左上の ✓、コマンドで固定中は灰色。カードの下に小さな[VRCFT(ETVR)](高さ 40、選んでいれば ✓ とオレンジの枠)と「削除予定」、ETVR の 2 行を「 ・ 」でつないで 1 行で。「VRChat に直接」を選んでいるときは、その小さなボタンの上に灰色の 1 行「目が動かないアバターは、VRCFT(LiveLink か FrameEyeOSC)にすると動きます」(`vrchatDirectHint`)。ETVR を選んでいるときだけ、カードとボタンのあいだにオレンジの案内「いまは VRCFT(ETVR)に送っています。次の版で ETVR はなくなります」「上の 3 つから、ほかの送り先を選び直してください」〔`outputDeprecated`〕。勝手にほかの送り先へは移さない)、目のカメラ(準備前は「使っていない」と[はじめる →](目のカメラタブへ。タブがあるときだけ)。準備後は「使う / 使わない」(`camera_lids`。0.7.6 までは目のカメラタブの「カメラで瞼を取る」)と短い状態: 左・右 OK / 左だけ / 右だけ / オフ / 目を覚えてる途中 / 待機中(`cameraBrief`))、言語(日本語 / English。右に[すべて既定に戻す])、SteamVR と一緒に起動(オン / オフ。右に[アプリを終了])。いちばん下の「押すとすぐ本体に反映され…」は入るときだけ(ETVR の案内が出ているときは省く) | | 送り方 | 送り先の PC(自動 / 今の相手で固定 / IP を入力。手で決めた host は 3 つ目に「手動 192.168.1.20」と出る)、ポート(− / +、既定に戻す。ヒントに送り先の種類の既定)。VRChat に直接のときだけ パラメーター名の頭(/FT / なし、送るアドレスの例)、EyeTrackingActive の型(Bool / Float / 送らない、`eye_tracking_active`)、Steam Link の名前も送る(オン / オフ、`steamlink_params`。Steam Link の OSC 向けのアバター用。送るアドレスの例に「頭はつけない」)、VRChat 標準の目も動かす(オン / オフ、`native_eyes`。VRCFT 用の値がないアバター用)。LiveLink / FrameEyeOSC / ETVR のときは「PC の VRCFT で準備すること」の手順(LiveLink と ETVR は 3 つ。FrameEyeOSC は 4 つ: PC のブラウザーで zip を落とす・「Module Registry」の[Install from .zip]・ほかの目のモジュールはオフ・UDP 11112。1 つ目の下にアクセント色の入手先 github.com/sasaken1102r/VRCFT-FrameEyeOSC/releases があり、押すと Frame の Chromium でそのページを開く〔`setup_tools::kModulePageUrl`〕。開けなかったら、そのすぐ下に赤の 1 行「ブラウザーを開けませんでした: 理由」〔目のカメラの準備の画面には出さない。次に開けたときと送り先が変わったときに消える。`--fake-module-link-error`〕)と注記(入りきらないとき〔LiveLink で目のカメラを使っているとき〕は注記を省く。FrameEyeOSC で目のカメラを使っているときは「瞳孔の大きさも、VRCFT がアバターに合った形で送ります。」を足す)。FrameEyeOSC には瞳孔の行が無い(VRCFT が送る)。目のカメラを使っていないときだけ点線の 1 行。目のカメラを使っているとき(`eyecam::inUse`: 準備が済んでいて `camera_lids` がオン)は、LiveLink なら「瞳孔は VRChat に直接送る」(`pupils_to_vrchat`)、VRChat に直接と LiveLink なら「瞳孔の受け取り方」(小数 / 1 / 2 / 3 / 4、`pupil_bits`。PupilDilation1・2・4・8 の bool も送る個数。LiveLink で `pupils_to_vrchat` がオフのときは灰色で押せない)。どちらも緑の「カメラ」バッジ付き。使っていないときはその場所に点線の 1 行「目のカメラを使うと、瞳孔の大きさも送れるよ」(押すと目のカメラタブ。`pupilsLineShown`) | -| 視線 | いちばん上の切り替えで 3 つの小タブ(詳細・まぶたタブと同じ形。初めては「動き」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `gaze_page` で覚えている)。「動き」: スムージング(オン / オフ)、なめらかさ(弱 / 中 / 強)、細かく変える(止まっている時・速い動き・変化の感度の 3 つを − / +)、見つめている時の遊び(角度も表示)。「動く範囲」: 視線の倍率(左・右、上・下の − 100% +。5〜200 %、5 % 刻み、`gaze_scale_left/right/up/down`。ヒント「目合わせのあとに掛けます」。左右・上下のあいだに鍵: 閉じている(アクセント色)あいだは片方を変えるともう片方も同じ値に、開く(灰色)と別々、閉じ直すと右 := 左・下 := 上(`gazeScaleStep` / `gazeScaleRelink`)。鍵は `panel.json` の `gaze_scale_link_x` / `_y` に覚え、起動時に値が違う組は開いて見せる(`gazeScaleLinkShown`)。どちらかがコマンドで固定中なら鍵は押せない)、左右の目を別々に動かす(Frame が片目だけで追っているあいだは、ヒントが「片目だけ追跡中は両目が同じ向き」になる)、その下に目ごとの視線(`own_eye_gaze` の − / +、0〜100 %、10 % 刻み。右に「0 % で安定、100 % で目ごと(ぶれる)」。「別々に動かす」がオフのときは薄くして押せなくし、v1 のときは薄くしたうえで右を「v2 だけで効くよ。今は v1 なので使ってない」と[詳細 › バージョンへ]に)。「乱れ対策」: まばたき中は視線を止める(オン / オフとしきい値)、不確かな視線を使わない(オン / オフと上限)、一瞬の途切れを消す(オン / オフ)。`--gaze-page motion|reach|glitch` で小タブを選んで描き、`--fake-gaze-scale L,R,U,D` で倍率を入れる | +| 視線 | いちばん上の切り替えで 3 つの小タブ(詳細・まぶたタブと同じ形。初めては「動き」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `gaze_page` で覚えている)。「動き」: スムージング(オン / オフ)、なめらかさ(弱 / 中 / 強)、細かく変える(止まっている時・速い動き・変化の感度の 3 つを − / +)、見つめている時の遊び(角度も表示)。「動く範囲」: 視線の倍率(左・右、上・下の − 100% +。5〜200 %、5 % 刻み、`gaze_scale_left/right/up/down`。ヒント「目合わせのあとに掛けます」。左右・上下のあいだに鍵: 閉じている(アクセント色)あいだは片方を変えるともう片方も同じ値に、開く(灰色)と別々、閉じ直すと右 := 左・下 := 上(`gazeScaleStep` / `gazeScaleRelink`)。鍵は `panel.json` の `gaze_scale_link_x` / `_y` に覚え、起動時に値が違う組は開いて見せる(`gazeScaleLinkShown`)。どちらかがコマンドで固定中なら鍵は押せない)、左右の目を別々に動かす(Frame が片目だけで追っているあいだは、ヒントが「片目だけ追跡中は両目が同じ向き」になる)、その下に目ごとの視線(`own_eye_gaze` の − / +、0〜100 %、5 % 刻み。右に「0 % で安定、100 % で目ごと(ぶれる)」。「別々に動かす」がオフのときは薄くして押せなくし、v1 のときは薄くしたうえで右を「v2 だけで効くよ。今は v1 なので使ってない」と[詳細 › バージョンへ]に)。「乱れ対策」: まばたき中は視線を止める(オン / オフとしきい値)、不確かな視線を使わない(オン / オフと上限)、一瞬の途切れを消す(オン / オフ)。`--gaze-page motion|reach|glitch` で小タブを選んで描き、`--fake-gaze-scale L,R,U,D` で倍率を入れる | | 目を合わせる | [目を合わせる](ヒント「正面・上の幅・まぶた・約 20 秒」。合わせたあとは[もう一度合わせる])と小さい[真ん中だけ合わせ直す]、「被ったとき」の行(何もしない / 真ん中だけ、`auto_recenter`。既定は真ん中だけ。0.7.6 までの `"tilt"` は `"center"` として読む)、今どうなっているか(始め方・結果[元に戻す]・止めた / 失敗の理由。被ったときの合わせ直しをダッシュボードが止めて、もう一度やるときは「ダッシュボードを閉じたら、もう一度合わせ直します」も)。目を合わせている間はタブ全体が「点を見てください」と何をするか(動かない文字)と大きな[やめる]だけになり、左の列とタブの並びは暗くなる(押せるのはタブと[やめる]だけ。ほかのタブを押すと止まる)、[♪ 音を鳴らす: オン / オフ]、「細かく直す」(開閉。[視線 / まぶた]で切り替え。視線: 正面の位置・動く幅(左右・上・下)、真下で左右を止める角度。まぶた: 目ごとの読んだ値 閉じ / 上 / 正面 / 下 の − / +。閉じているあいだは、その下に「合わせ方」の箱: 被るたびは真ん中の点を 1 つ見るだけ(自動)、[目を合わせる]は最初と気になったときだけ、傾きは使わなくなったこと) | | まぶた | 見た目の調整を全部ここに(案E)。いちばん上の切り替えで 4 つの小タブ(詳細タブと同じ形。初めては「見た目」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `lids_page` で覚えている)。「見た目」: 「いまのまぶた」の帯(目のカメラ(左・右)/ 目のカメラ(左)+ Valve(右)/ 目のカメラ(待機中)/ Valve の値。準備が済んでいれば右に「オン・オフは「基本」タブで」)、「見開き」の行(する / しない で `widen` を `SetBool`。カメラを使っていてもいなくてもいつも出す。ヒント「閉じ・細めはそのまま」。「しない」のあいだは下の「見開きの出やすさ」を灰色にして押せなくし、その下の 1 行を「「見開き」が「しない」なので、いまは使わないよ」に。目のカメラタブのいちばん下の文も「カメラで両目を見て、まぶたを送ってるよ(見開きはしない)」に、診断の「まぶた」の行には「· 見開きしない」を足す。`--fake-no-widen` で描ける)、目のカメラを使っているときだけ「見開きの出やすさ」のスライダー 1 本(緑の「カメラ」バッジ。鈍い ↔ 敏感。両目がカメラなら eyecam-rec の `widen_sensitivity` を `set widen_sensitivity` で送る。片目だけカメラなら同じく感度を送り、Valve の目には近い `lid_widen` の段階も一緒に書く。Valve の値なら `lid_widen` の 4 段階(しない / 控えめ / ふつう / 出やすい)で、押した所に近い止まり位置へ。目を合わせていない目があると下に「目を合わせていない目は、「細かく直す」の目盛りで決まるよ」。いまカメラの目が無く、ふつうに開いた目が 1.0 と読まれる SteamOS(`openness_saturated`)では、スライダーの代わりに 1 行: ヒント「この SteamOS では効かないよ」、「Valve の値だけだと見開きが出ないよ」と[目のカメラへ →](目のカメラタブがあるときだけ))、目のカメラを使っていて eyecam-rec の status.json に `lid_veto.on` があるときだけ「半目の読み直し(AI)」(する / しない。緑の「カメラ」バッジ。eyecam-rec に `set lid_veto on|off` を送り、選ばれているほうは status.json の `lid_veto.on` のまま〔panel.json には持たない〕。下に 3 行までの説明。`--fake-eyecam …:veto=on|off` で描ける)、まぶたのなめらかさ(弱 / 中 / 強。中 = 本体の既定値、どれにも合わなければカスタム)、左右をそろえる強さ(`lid_sync` のスライダー、0.05 刻み)。目のカメラを使っていないときは、いちばん下に点線の 1 行「目のカメラを使うと「見開きの出やすさ」が増えるよ」(押すと目のカメラタブ)。なので、カメラが無いと `lid_widen` はパネルから変えられない。「まばたき・細め」: まばたきを届ける(保持 ms の − / +)、まばたきを両目でそろえる(`blink_sync_below` の − / +、0 でオフ)、細めたときのまぶたの下限(`camera_lid_floor` の − / +、0.05 刻み、0 でオフ。目のカメラを使っているときだけ出す。緑の「カメラ」バッジ。カメラの目がまだないときは「目のカメラが目を見てるときに効くよ」と添える。使っていないときは点線の 1 行「目のカメラを使うと「細めたときのまぶたの下限」も使えるよ」)。それぞれ右に説明。「細かく直す」: いちばん上に目盛りの決まり方の 1 行、自動キャリブレーション(目を合わせていない目で、カメラでないとき)、左右の倍率、生の値の棒と 4 つの目盛り(③④は目を合わせた目とカメラのときは灰色)、ほぼ開いてたら全開にする(⑤。生の値の棒にも ⑤ の青い破線を出す。目を合わせた目は倍率をかけた位置、カメラが読んでいる目は薄く、0.75 のときは出さない。行の数字と − / + の縁も同じ青。`lid_open_snap` の − / +、送るまぶたの値で 0〜0.75 の 0.01 刻み、0.75 でオフ。いつも出す。右に説明。開いた値に対する割合だったころの 0.75 より大きい値は、起動時に一度だけ置き換える(`migrateLidOpenSnap`))、なめらかさの 2 つの値(止まっている時・速い動き。ページに収まるよう、それぞれの左に名前を置いた 1 行)。目のカメラタブの[まぶたへ →]は「見た目」を開く。「眉」: 「見開きで眉も上げる」(する / しない で `brow_from_widen` を `SetBool`。ヒント「カメラは眉を見ていないよ」。カメラを使っていてもいなくても、「見開き」が「しない」でもいつも出す。下に 3 行までの説明〔`\n` の所で改行〕)と、「する」のあいだだけ「眉の上がり方」(`brow_strength` のスライダー、弱い ↔ 強い、0.05 刻み。ヒント「アバターに合わせて」)。`--fake-brows` で「する」を描ける。`--lids-page look|blinks|fine|brows` で小タブを選んで描く(`--lid-marks` は fine と同じ) | | 目のカメラ | eyecam-rec が動いているあいだだけ出る(「詳細」の前。出る条件は下の「目の撮影」と同じ)。準備を始める前(準備が済んだことがなく、まだ ① か ② の段で、このパネルを起動してから[はじめる]を押していないとき。`eyecam::introShown`)は、「目のカメラでできること」(見開き・瞳孔の大きさ・Valve がまぶたを低く読んだときにカメラで確かめて直す)、1 回だけの準備と目の映像は Frame の外に出さないことの箱、[はじめる(約 2 分)]、「使わなくても、目の送信はふつうに動きます」。押したあと(と準備が進んでいるとき)は準備のチェックリスト(いまの段階は、満たしていない最初のもの。毎回 status.json とパスワードの確認だけで決める): ① パスワードを決める(SteamOS にパスワードが無いと分かっているときだけ。`steamos-passwd --has-password` を裏で数秒おきに確かめる。読むだけ。[Konsole で passwd を開く])② 道具を入れる([Konsole で開く]で `sudo $HOME/.local/lib/eyecam/install_grab.sh` が入力済みの Konsole を開くだけ。Enter とパスワードは本人で、パネルは sudo も passwd も実行しない。`auto_grab` が ok / trying / waiting_tracker / failed: … か、バッファを持っていれば入っている扱い。`auto_grab` が `too_old`(eyecam の安全のための下限より古く、起動されない)なら入っていない扱い。`grab_outdated` が true(更新で新しい道具が届いた)か too_old なら、見出しを「道具を入れ直す」、その下に「道具が新しくなったよ、入れ直してね」)③ 目の動きを覚える(カメラの映像は「届いてるよ」、`cam_fps` が毎秒 60 枚より少なければアクセント色で「届いてるよ(毎秒 15 枚・少なめ)」。目ごとに「見えてるよ」(status.json の `pupil_l` / `pupil_r` で瞳が 50 % 以上)・「映像はあるけど瞳が見つからない」(`locked` だけど 20 % 未満、赤)・「瞳がときどきしか見えない」(そのあいだ)・「見えてないよ」(`locked` が false)。`pupil_l` / `pupil_r` の無い古い eyecam-rec では今までどおり `locked` だけで決める。目の映像が届いているとき(`locked`)は[覚えはじめる]で `calib wear` を押せて、瞳が見つからない目があるときはその下に赤字で「HMD をかぶり直して」と出す。18 秒の間、ステップのチップ・残り秒・[やめる]をカードの中に出す。失敗したら eyecam-rec のメッセージ(数も入っている)の上に、メッセージが名前を出した目(「うまくいかなかった目」)を出す。見開きだけ取れなかったら[このまま進む][もう一度(18秒)]。片目だけ失敗して通ったとき(`calib_failed_eye` が L / R)は「左目は前の値を使うよ」(初めてなら「仮の値」)と目ごとの結果、メッセージ、[使いはじめる][もう一度(18秒)])④ 完了(「使いはじめる」)。セットアップ済みでも道具が無くなれば ② に戻り、直れば校正し直さずにふだんの画面へ。道具が古いだけなら ② には戻さず、ふだんの画面のいちばん上にお知らせのカードと[Konsole で開く](`grab_outdated`: 「道具が新しくなったよ、入れ直してね(今の道具のままでも動くよ)」、`too_old`: 赤いカードで「安全のため、道具を入れ直してね。入れ直すまでカメラは止まってて、まぶたは Valve の値だけで送ってるよ」)、左の列にも同じお知らせ(押すとこのタブ)。eyecam-rec が言わなくなれば自然に消える(`eyecam::toolState` / `toolNotice`。`--fake-eyecam …:outdated`、`waiting:auto=too_old:setup:outdated` で描く)。左の列には、準備が済むまで「次にやること・目のカメラ」、済んだ直後に緑の「目のカメラ:準備できたよ」。済んだあとのふだんの画面: いまの状態(両目・片目・Valve の値とその理由。ヒントは「オン・オフは「基本」タブで」。カメラのスイッチ `camera_lids` は「基本」タブに移した。オフのあいだの校正のボタンは「「基本」タブで目のカメラを「使う」にしてから」)、その下の行の右に[普段の目を覚え直す](英語は Relearn my eyes。左に「約 30 秒。そのあいだも今の値で動くよ」。eyecam-rec に `relearn` を送り、覚え始めがやり直しになって、いまの状態の右のピルが「普段の目を覚えてるよ(あと N 秒)」に戻る。覚えているあいだも、もう覚えていた目は前の値でまぶた・見開き・細めを送り続ける(`calib_state` の 4 はそのまま)。押せるのは `eyecam::relearnAllowed`: eyecam-rec の状態が新しく、idle・live・目の映像あり〔`locked`〕・`baseline` を書く eyecam-rec、`camera_lids` がオン、カメラがまぶたを動かしているか覚えている途中〔`relearnUse`〕、ほかのコマンドの返事待ちでない)、違和感があるときの[目のカメラの校正(18秒)]、細めも送るときの[ユーザー校正(最初に 1 回)](どちらも ③ と同じカードで進み、終わると結果のカード)、こんなときは。見開きの出やすさは「まぶた」タブ。`--fake-eyecam` と `--fake-password`、`--fake-camera` で描く | diff --git a/panel/src/config.cpp b/panel/src/config.cpp index 92bae42..e6fc62c 100644 --- a/panel/src/config.cpp +++ b/panel/src/config.cpp @@ -150,7 +150,7 @@ const std::vector& settingSpecs() { {key::kGazeDeadzone, SettingType::Number, 0.005, "", 0.0, 0.2, 0.005, 3}, {key::kGazeHoldBelow, SettingType::Number, 0.5, "", 0.05, 1.0, 0.05, 2}, {key::kIndependentEyes, SettingType::Bool, 0, "", 0, 1, 1, 0}, - {key::kOwnEyeGaze, SettingType::Number, 0.0, "", 0.0, 1.0, 0.1, 2}, + {key::kOwnEyeGaze, SettingType::Number, 0.0, "", 0.0, 1.0, 0.05, 2}, {key::kLidMinCutoff, SettingType::Number, 6.0, "", 0.5, 30.0, 0.5, 1}, {key::kLidBeta, SettingType::Number, 5.0, "", 0.0, 30.0, 0.5, 1}, {key::kLidClosed, SettingType::Number, 0.30, "", 0.0, 1.5, 0.01, 2}, diff --git a/panel/tests/model_test.cpp b/panel/tests/model_test.cpp index 6d2f6ac..e916158 100644 --- a/panel/tests/model_test.cpp +++ b/panel/tests/model_test.cpp @@ -827,9 +827,9 @@ void testBrowSettings() { void testOwnEyeGazeSetting() { const SettingSpec* spec = findSetting(key::kOwnEyeGaze); CHECK(spec != nullptr && spec->type == SettingType::Number && spec->defaultNumber == 0.0); - CHECK(spec != nullptr && spec->min == 0.0 && spec->max == 1.0 && spec->step == 0.1); + CHECK(spec != nullptr && spec->min == 0.0 && spec->max == 1.0 && spec->step == 0.05); if (spec != nullptr) { - CHECK(stepValue(*spec, 0.0, 1, -1e9, 1e9) == 0.1 && stepValue(*spec, 0.2, 1, -1e9, 1e9) == 0.3); + CHECK(stepValue(*spec, 0.0, 1, -1e9, 1e9) == 0.05 && stepValue(*spec, 0.05, 1, -1e9, 1e9) == 0.1); CHECK(stepValue(*spec, 1.0, 1, -1e9, 1e9) == 1.0 && stepValue(*spec, 0.0, -1, -1e9, 1e9) == 0.0); CHECK(stepValue(*spec, 0.37, -1, -1e9, 1e9) == 0.3 && snapValue(*spec, 1.4) == 1.0); } diff --git a/panel/tests/panel_test.cpp b/panel/tests/panel_test.cpp index cf08ab6..e072ceb 100644 --- a/panel/tests/panel_test.cpp +++ b/panel/tests/panel_test.cpp @@ -1085,7 +1085,7 @@ void testGazePages(const FontSet& fonts) { } } -/** own_eye_gaze (Gaze > Reach, "Own gaze per eye", under "Move eyes separately"): − / + in steps of 10 % only while +/** own_eye_gaze (Gaze > Reach, "Own gaze per eye", under "Move eyes separately"): − / + in steps of 5 % only while * the eyes move separately in v2 and it isn't locked; faded otherwise, in v1 with the button to that switch. Inside * the card in both languages. */ void testOwnEyeGazeRow(const FontSet& fonts) { From c1173e0cf2a70739ba401018c005f2cc7c8c0c7f Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sat, 24 Oct 2026 10:21:17 +0900 Subject: [PATCH 22/65] =?UTF-8?q?=E6=9C=AC=E4=BD=93:=20=E7=9B=AE=E3=81=AE?= =?UTF-8?q?=E3=83=87=E3=83=BC=E3=82=BF=E3=81=8C=E5=B0=91=E3=81=AA=E3=81=84?= =?UTF-8?q?=E3=81=A8=E3=81=8D=E3=81=AE=E3=83=AD=E3=82=B0=E3=81=A7=E3=80=81?= =?UTF-8?q?=E5=8F=96=E3=82=8A=E3=81=93=E3=81=BC=E3=81=97=200=20=E4=BB=B6?= =?UTF-8?q?=E3=81=8C=E3=80=8C-0=E3=80=8D=E3=81=A8=E5=87=BA=E3=82=8B?= =?UTF-8?q?=E3=81=AE=E3=82=92=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f32 の空の合計は -0.0 になり、{:.0} で「-0」と出ていた(#41 のログ)。件数は整数で足してから f32 にする。テストで「0 published samples」を確かめる。 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.ja.md | 1 + CHANGELOG.md | 1 + src/main.rs | 8 +++++--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index eda028b..222bdb7 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -10,6 +10,7 @@ SteamOS が開いた片目を閉じていると読み続けても、視線が止 - 目のカメラを使ったまぶた(SteamOS 0.4.x): SteamOS の目のトラッキングは開いた目をカメラよりかなり低く読むことが多く、両者がそろわないまま、まばたきのたびにカメラの遅れの分だけまぶたの「疑い」が積み重なって、最後はカメラがずっとまぶたを動かすようになり、カメラが見逃したまばたきは開いたまま送られていました。今は、SteamOS が目を 1 秒開いていると読んだら疑いをやり直します。流し直しでは、右目がカメラのまぶたになっていた時間が 0.67 時間のうち 130 秒から 111 秒になり、まぶたのサンプルの 0.3〜0.6 % が変わりました - eyecam、ユーザー校正: 細めは、そのかぶりの「開いた〜閉じた」の幅に対する割合で保存されていたのに、使うときは普段の幅に掛けていました。この幅はかぶるたびに 0.6〜1.5 倍違うので、校正した細めの深さが 0.65〜1.5 倍ずれていました。今はユーザー校正で細めの深さそのものも保存し(各目のユーザーの値の `sq_depth`)、使うときもそれを、校正と同じ上限・下限の中で使います。案内に沿った「普通」の段で開いた目が細めと読まれたのは 2.4 % から 0.8 % になり、いちばん細めたときは少し低めに読まれます(0.9 以上が 53 % から 35 %)。新しくユーザー校正をしたあとから効き、前の校正は今まで通り動きます。細めが浅すぎると感じたら、ユーザー校正をやり直してください。前の版に戻してそこで校正を保存すると `sq_depth` が消えるので、更新したあとにユーザー校正をやり直してください - eyecam、度付きインサート: 目のカメラの前にインサートがあると、閉じた目が少し開いていると読まれたり、虹彩が大きすぎると測られて閉じが見えにくくなったりしていました。閉じた目で見つけた下まぶたの線が、開いていたときより虹彩の半径の 0.9 倍以上下にあるときや、探す範囲の下端に張り付いているときは使わず、最後に瞳が見えていたときの線を使います(下まぶたはまばたきでほとんど動かないため)。瞳が欠けずに見えているのに上まぶたの線が瞳の中心より下にあるときも、その線は使いません。そういうかぶりの校正のサンプルで測り直すと、左目の「開いた〜閉じた」の幅は 0.30 から 1.35 になりました(右目は 1.31)。かぶりの虹彩の半径が開いた瞳の 4 倍を超えたら、虹彩の縁をいちばん強い明るさの変わり目ではなく、虹彩から出て最初の立ち上がりで測り、半径と開いた目の iris box の明るさを覚え直します。半径を 61 / 54 px から 79 / 75 px に引っぱる帯を足した録画 26 本で流し直すと、半径は 72 / 67 px、閉じの取りこぼしは 1 分あたり 1.27 回から 1.08 回、0.2 秒未満のまばたきは 80 % から 83 % 取れるようになり、そのかわり開いた目が半目と読まれたのは 5.3 % から 6.4 % になりました。元の録画では半径の切り替えは起きず、案内に沿った閉じが取れたのは 97.3 % から 98.2 % で、ほかはほぼ同じでした(開いた目が半目と読まれたのは 5.3 % から 5.4 %)。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください +- ログ: 「Eye data has been low」の行で、取りこぼしがないときに「-0 published samples were missed」と出ていたのを、0 と出すようにしました。 ## 0.8.1 (2026-10-21) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63b4f0b..a479556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The gaze no longer stays frozen when SteamOS keeps reading one open eye as close - Eyelids with the eye cameras on SteamOS 0.4.x: its eye tracker often reads an open eye well below the camera, so the two never agreed and each blink's camera lag added to the eyelid's "doubt" until the camera drove the eyelid for good, and blinks the camera missed went out open. The doubt now starts over once the eye tracker has read the eye open for 1 s. In replays the right eye's time on the camera eyelid went from 130 s to 111 s in 0.67 h; 0.3-0.6 % of eyelid samples changed. - eyecam, user calibration: the squint was saved as a fraction of that wear's open-to-closed gap but used against the usual gap, which differs by 0.6 to 1.5 times from wear to wear, so the calibrated squint came out 0.65 to 1.5 times as deep. The user calibration now also saves the squint's depth (`sq_depth` in each eye's user values), and live uses it, kept within the same limits as the calibration's. Open eyes read as a squint went from 2.4 % to 0.8 % in the guided normal step; a full squint reads a little lower (0.9 or more 35 % of the time instead of 53 %). This takes effect after a new user calibration; older calibrations work as before. If your squint now reads too shallow, run the user calibration again. Going back to an older version and saving a calibration there drops `sq_depth`, so run the user calibration again after updating. - eyecam, prescription inserts: with an insert in front of the eye cameras, a closed eye could read partly open, and an iris fit far too large made closes harder to see. A lower lid line found on a closed eye 0.9 iris radii or more below where it was with the eye open, or against the bottom of its search band, is no longer taken: the last one seen with the pupil is used instead (the lower lid hardly moves in a blink), and an upper lid line below the centre of a pupil seen whole is dropped. Re-fitting one such wear calibration's samples, the left eye's open-to-closed gap goes from 0.30 to 1.35 (its right eye: 1.31). When a wear's iris radius comes out over 4 times the open pupil's, the iris edge is taken at the first rise out of the iris instead of the strongest edge, and the radius and the open eye's iris-box level are learnt again. Replayed on 26 recordings with a band added that pulls the radius from 61 / 54 px to 79 / 75 px: 72 / 67 px, closes missed 1.08 instead of 1.27 a minute, blinks under 0.2 s caught 83 % instead of 80 %, but open eyes read half-shut 6.4 % of the time instead of 5.3 %. On the original recordings the radius switch never happens; guided closes were caught 98.2 % of the time instead of 97.3 %, and everything else stayed about the same (open eyes read half-shut 5.4 % of the time instead of 5.3 %). If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. +- Logs: the "Eye data has been low" line said "-0 published samples were missed" when none were; it now says 0. ## 0.8.1 (2026-10-21) diff --git a/src/main.rs b/src/main.rs index 6690575..f75e2d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4331,10 +4331,10 @@ impl Pace { self.prune(now); } - /// Samples missed in the last RATE_WINDOW. + /// Samples missed in the last RATE_WINDOW. Summed as integers: an empty f32 sum is -0.0, which logs as "-0". fn missed_rate(&self, now: Instant) -> f32 { let recent = self.missed.iter().filter(|(time, _)| now.duration_since(*time) < RATE_WINDOW); - recent.map(|(_, missed)| *missed as f32).sum() + recent.map(|(_, missed)| *missed).sum::() as f32 } /// The longest one sample took in the last RATE_WINDOW, in ms (0 without samples), to 0.1 ms. @@ -8293,7 +8293,9 @@ mod tests { assert_eq!(bridge.check_low_rate(start + Duration::from_secs(21)), None); bridge.received.truncate(46); assert_eq!(bridge.check_low_rate(start + Duration::from_secs(22)), None); - assert!(bridge.check_low_rate(start + Duration::from_secs(32)).is_some()); + let line = bridge.check_low_rate(start + Duration::from_secs(32)).unwrap(); + // Nothing missed in the last second: "0", not "-0" + assert!(line.contains("in the last second 0 published samples were missed"), "{line}"); // Not tracking: no rate, nothing to say bridge.active_since = None; assert_eq!(bridge.check_low_rate(start + Duration::from_secs(50)), None); From c2369e876e57e65f59463af904febd619466a6f9 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sat, 24 Oct 2026 15:53:38 +0900 Subject: [PATCH 23/65] =?UTF-8?q?eyecam:=20=E3=81=8B=E3=81=B6=E3=82=8A?= =?UTF-8?q?=E3=81=AE=E6=A0=A1=E6=AD=A3=E3=81=AE=E8=A6=8B=E9=96=8B=E3=81=8D?= =?UTF-8?q?=E3=81=A7=E3=80=81=E9=AB=98=E3=81=84=E7=AF=84=E5=9B=B2=E3=81=AE?= =?UTF-8?q?=E7=B7=9A=E3=81=8C=E6=99=AE=E6=AE=B5=E3=81=AE=E6=AE=B5=E3=81=8B?= =?UTF-8?q?=E3=82=89=E4=B8=8A=E3=81=8C=E3=81=A3=E3=81=9F=E5=88=86=E3=82=92?= =?UTF-8?q?=E5=B9=85=E3=81=AB=E3=81=97=E3=80=810.9=20=E3=82=88=E3=82=8A?= =?UTF-8?q?=E5=A4=A7=E3=81=8D=E3=81=84=E5=B9=85=E3=81=AF=E8=A6=8B=E9=96=8B?= =?UTF-8?q?=E3=81=8D=E3=81=A8=E3=81=BF=E3=81=AA=E3=81=95=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 度付きインサートの人の録画(2026-10-23): 普段の段の線(0.73 / 0.68、まぶたの縁)と、見開きの段の高い範囲の線 (まつ毛の先、2.01 / 1.88)を比べていたので、見開きの幅が 1.30 / 1.15 になっていた(0.8.2-beta.1 の校正で 1.165 / 1.054)。 高い範囲は普段の段でも同じまつ毛の先を 1.69 / 1.67 に見つけている - 高い範囲が普段の段のフレームの 1/4 以上でも線を見つけていて(inlier 0.5 以上)、見開きの線がその中央値より上なら、 見開きの幅はその上がった分にする(この録画で 0.33 / 0.23)。calib_result.json に widen_upper_rose_from、ログに rose from - 見開きの幅が 0.9(MAX_WIDEN_STEP)より大きいときは見開きが取れなかった扱い(いつもの幅、履歴に入れない)。 履歴の幅の中央値からも 0.9 より大きいものは除く。開発者の 47 回は 0.15〜0.43 - 開発者の dev 校正 20 回の replay は前と同じ値(増えたキーを除く) Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/README.md | 11 ++-- tools/eyecam/src/live.rs | 139 ++++++++++++++++++++++++++++++++++----- 2 files changed, 128 insertions(+), 22 deletions(-) diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index 5738577..659bd09 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -576,8 +576,9 @@ python3 ~/eyecam-src/tools/drift_report.py ~/eyecam/drift/drift_….csv # 5 「。カメラの映像が毎秒 15 枚しか届いていない」が付く(英語 ". The eye cameras deliver only 15 frames a second")。 calib_result.json の `thresholds` に目ごと `[左, 右]` の `fps` と要った数(`normal_frames_min` など)、決め方(`rule`)が入る - `calib user` は、同じかぶりで `calib wear` が済んでいないと断る。視線は Valve の値を使うので、frameeyeosc が動いている必要がある -- **`calib wear` で閉じる・普段は取れたのに見開きが取れなかったとき(見開きのフレームが足りない、幅が 0.05 未満)は、失敗にしない**。 - 普段の開き・閉じた開きはこの校正の値を使い、見開きの幅は履歴の中央値(なければ既定の 0.19)。この回の幅は履歴に入れない。 +- **`calib wear` で閉じる・普段は取れたのに見開きが取れなかったとき(見開きのフレームが足りない、幅が 0.05 未満か 0.9 より大きい)は、失敗にしない**。 + 0.9 より大きい幅は見開きではなく、普段と見開きで別の線を取ったもの(開発者の 47 回で 0.15〜0.43、度付きインサートの人の 0.8.2-beta.1 で 1.165 / 1.054)。 + 普段の開き・閉じた開きはこの校正の値を使い、見開きの幅は履歴の中央値(0.9 より大きい幅は除く。なければ既定の 0.19)。この回の幅は履歴に入れない。 `message` は「校正できた(見開きは取れなかったので、いつもの幅を使うよ)」、`last_calib_widen` は `default`、`calib_state` の 1 は立つ - **`calib wear` で片目だけ失敗したとき(瞳が見えない・上まぶたの線が取れない・閉じが検出できない)も、失敗にしない**。 通った目はこの校正の値、失敗した目は前の `calib wear` の値(calib.json の `wear`。なければもう片目の値〔虹彩の半径の単位〕に、 @@ -637,9 +638,11 @@ python3 ~/eyecam-src/tools/drift_report.py ~/eyecam/drift/drift_….csv # 5 瞳があるフレームだけ。ほかは空〕)。 見開きの段で、瞳が見えたフレームの半分以上に普段の線の近く(普段より虹彩の半径の 0.2 倍下まで)の線がないときは、まぶたが探す範囲の 上に出たとみて、高い範囲(瞳の中心から虹彩の半径の 1.6〜3.2 倍上)の線から見開きを測る(inlier 0.75 以上、普段から 1.5 倍以内、 - 前後のフレームと 0.1 以内、普段の段にも同じ高さで出る線〔しわ・眉〕は除く)。足りなければ今までどおり。目ごとの `widen_source` + 前後のフレームと 0.1 以内、普段の段にも同じ高さで出る線〔しわ・眉〕は除く)。足りなければ今までどおり。 + 高い範囲が普段の段のフレームの 1/4 以上でも線を見つけていて(inlier 0.5 以上)、見開きの線がその中央値より上なら、同じ線が見開きで + 上がったとみて(度付きインサートの人の長いまつ毛の先: 普段 1.69 → 見開き 2.01)、見開きの幅はその上がった分にする(`widen_upper_rose_from` にその中央値)。目ごとの `widen_source` (`main` / `upper` / `fallback`)と数(`widen_frames_no_line_near_normal`・`widen_frames_upper_good`・`widen_frames_upper_used`・ - `widen_upper_fixed_line`)が calib_result.json に入る。 + `widen_upper_fixed_line`・`widen_upper_rose_from`)が calib_result.json に入る。 wear の `calib_result.json` には目ごとに瞳の探し方の診断 `diag` も入る(目の画像がなくても、瞳が見えなかった原因をこれで見分ける): `fps`(フレームレート)、`search_x_min` / `search_xmax`(使った枠の左右)、`normal_pupil_x` / `normal_pupil_y`(普段の段の瞳の位置の中央値)、 `normal_contrast`(普段の段の、枠の明るさの中央値 − 暗いところ。開発者で 60〜100)、`steps`(段ごとに `frames`・`pupil`・`lid_line`、 diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index ea1ff02..90c03e7 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -1631,7 +1631,7 @@ pub fn fit_wear_eyes(samples: &[Vec; 2], fallback_step: [f64; 2]) -> [Ey if nn < need.normal { return Err(WearFail::LidLine { seen: nn, frames: normal.len(), need: need.normal }); } - let widen_ok = nw >= need.widen && b_w - b_n >= MIN_WIDEN_STEP; + let widen_ok = nw >= need.widen && b_w - b_n >= MIN_WIDEN_STEP && b_w - b_n <= MAX_WIDEN_STEP; let b_w = if widen_ok { b_w } else { b_n + fallback_step[e] }; if nc < need.close || ap_n - ap_cl < MIN_OPEN_GAP || (ap_n - ap_cl).is_nan() { return Err(WearFail::Close); @@ -2156,13 +2156,18 @@ impl CalibFile { [0, 1].map(|e| self.wear.map(|w| w[e]).filter(|_| self.wear_failed_eye != ["L", "R"][e])) } - /// The medians of the history (None when it is empty). + /// The medians of the history (None when it is empty). Widen steps above MAX_WIDEN_STEP (saved before that check) + /// are left out of the step's median; with none other left it is DEFAULT_WIDEN_STEP. pub fn history_params(&self) -> Option<[History; 2]> { if self.history.is_empty() { return None; } + let step = |e: usize| { + let m = median_of(self.history.iter().map(|x| x.1[e].step).filter(|s| *s <= MAX_WIDEN_STEP)); + if m.is_finite() { m } else { DEFAULT_WIDEN_STEP } + }; let h = |e: usize| History { - step: median_of(self.history.iter().map(|x| x.1[e].step)), + step: step(e), gap: median_of(self.history.iter().map(|x| x.1[e].gap)), r_px: median_of(self.history.iter().map(|x| x.1[e].r_px)), }; @@ -2317,6 +2322,12 @@ impl CalibFile { /// Thresholds of the calibration checks (fit_wear / fit_user use the same numbers). pub const MIN_WIDEN_STEP: f64 = 0.05; +/// A widen step above this (iris radii) is not a widen: a lid's edge does not rise that far above where it sits +/// looking ahead, so two different lines were taken for the normal and the widen level. The eye gets the usual step +/// (as with a widen not caught), and such a step is not taken into the history's median either. The developer's +/// 47 wear calibrations: 0.15 to 0.43. A tester with lens inserts (0.8.2-beta.1): 1.165 / 1.054, the usual band's +/// normal level against the tips of the upper lashes in the higher band (see WIDEN_HIGH_REF_FRAC). +pub const MAX_WIDEN_STEP: f64 = 0.9; pub const MIN_OPEN_GAP: f64 = 0.2; /// A widen frame whose upper lid line lies more than this (iris radii) below the normal level is left out of the /// widen level: a widened lid is never lower than the normal one, so such a line is not the lid. Some wearers' lid @@ -2354,6 +2365,15 @@ pub const WIDEN_HIGH_NEIGHBOUR: f64 = 0.1; /// of the normal frames with the pupil (a crease or the brow, there whether the eye widens or not). pub const WIDEN_HIGH_FIXED: f64 = 0.15; pub const WIDEN_HIGH_FIXED_SHARE: f64 = 0.25; +/// When the higher band finds a line in at least WIDEN_HIGH_FIXED_SHARE of the normal frames with the pupil (inlier +/// share at least this), and the widen lines lie above that line's median, they are the same line moved up with the +/// widening lid, not the lid's edge: the widen step is how far that line rose, added to the normal level (the usual +/// band's). A tester with lens inserts (0.8.2-beta.1, a recording of 2026-10-23): the usual band's normal level is the +/// lid's edge (0.73 / 0.68 R), while the higher band sees the tips of the long upper lashes in both steps (normal +/// 1.69 / 1.67, widen 2.01 / 1.88). Measured from the normal level the step came out 1.30 / 1.15, so the widen could +/// never be reached; the lashes' rise gives 0.3 / 0.2. Their normal lines are weaker than the widen ones (median +/// share 0.64 on the right eye), hence a lower bar than WIDEN_HIGH_MIN_FRAC. +pub const WIDEN_HIGH_REF_FRAC: f64 = 0.5; /// Where a wear calibration's widen level came from (calib_result.json's `widen_source`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -2379,7 +2399,9 @@ impl WidenSource { /// One eye's widen level (`widen_fit`): the level and how many frames it is from (NEED_WIDEN counts them), where it /// came from, the widen frames with the pupil and those of them without a lid line near the normal level (the -/// trigger), the higher band's good lines and those taken, and the line it also finds in the normal frames (NaN: none). +/// trigger), the higher band's good lines and those taken, the line it also finds in the normal frames (NaN: none), +/// and the level its lines have in the normal frames that the widen ones rose from (WIDEN_HIGH_REF_FRAC; NaN: not +/// used). #[derive(Clone, Copy, Debug, PartialEq)] pub struct WidenFit { pub b_w: f64, @@ -2390,20 +2412,22 @@ pub struct WidenFit { pub high_good: usize, pub high_used: usize, pub fixed: f64, + pub rose_from: f64, } /// The widen level from one eye's samples `s` (all steps, in one radius) for normal level `b_n`, `need` frames /// needed. Usually `widen_level`. When most widen frames with the pupil have no lid line near the normal level /// (WIDEN_LOW_SHARE), the median of the higher band's lines (`Sample::skin2`) that pass the WIDEN_HIGH_* checks; with -/// fewer than `need` of those, `widen_level` as before. For a wearer whose lid stays in the usual band this is -/// `widen_level` exactly. +/// fewer than `need` of those, `widen_level` as before. When those lines lie above the line the higher band finds in +/// the normal frames, the level is the normal level plus how far that line rose (WIDEN_HIGH_REF_FRAC). For a wearer +/// whose lid stays in the usual band this is `widen_level` exactly. pub fn widen_fit(s: &[Sample], b_n: f64, need: usize) -> WidenFit { let widen: Vec<&Sample> = s.iter().filter(|x| x.label == Label::Widen).collect(); let with_pupil = widen.iter().filter(|x| x.ok).count(); let low = widen.iter().filter(|x| x.ok && (x.skin.is_nan() || x.skin < b_n - WIDEN_MIN_BELOW_N)).count(); let main = |source| { let (b_w, n) = widen_level(s, b_n); - WidenFit { b_w, n, source, with_pupil, low, high_good: 0, high_used: 0, fixed: f64::NAN } + WidenFit { b_w, n, source, with_pupil, low, high_good: 0, high_used: 0, fixed: f64::NAN, rose_from: f64::NAN } }; if with_pupil == 0 || (low as f64) < WIDEN_LOW_SHARE * with_pupil as f64 { return main(WidenSource::Main); @@ -2428,8 +2452,17 @@ pub fn widen_fit(s: &[Sample], b_n: f64, need: usize) -> WidenFit { if high_used < need.max(1) { return WidenFit { high_good, high_used, fixed, ..main(WidenSource::Fallback) }; } - let b_w = vision::median(&mut used); - WidenFit { b_w, n: high_used, source: WidenSource::Upper, with_pupil, low, high_good, high_used, fixed } + let high = vision::median(&mut used); + // The higher band's level in the normal frames: the widen lines above it are that line moved up. + let (ref_n, n_ref) = + med_of(s, |x| (x.label == Label::Normal && x.ok && x.frac2 >= WIDEN_HIGH_REF_FRAC).then_some(x.skin2)); + let rose_from = if n_ref > 0 && n_ref as f64 >= WIDEN_HIGH_FIXED_SHARE * normal_ok as f64 && high > ref_n { + ref_n + } else { + f64::NAN + }; + let b_w = if rose_from.is_nan() { high } else { b_n + (high - rose_from) }; + WidenFit { b_w, n: high_used, source: WidenSource::Upper, with_pupil, low, high_good, high_used, fixed, rose_from } } /// How many frames a calibration check needs, relative to what the cameras delivered: at least `share` of the @@ -2587,7 +2620,7 @@ pub fn wear_report(samples: &[Vec; 2]) -> (String, Vec) { \"normal_frames_with_pupil\": {n_pupil}, \"normal_frames_with_lid_line\": {nn}, \"widen_frames_with_pupil\": {nw}, \"widen_frames_below_normal\": {below}, \"close_frames_with_lids\": {nc}, \ \"pupil_seen_share\": {{\"normal\": {}, \"widen\": {}, \"close\": {}}}, \"diag\": {}, \ \"widen_source\": \"{}\", \"widen_frames_no_line_near_normal\": {}, \"widen_frames_upper_good\": {}, \"widen_frames_upper_used\": {}, \ -\"widen_upper_fixed_line\": {}}}", +\"widen_upper_fixed_line\": {}, \"widen_upper_rose_from\": {}}}", ["L", "R"][e], json::num(r_px), json::num(b_n), @@ -2605,10 +2638,11 @@ pub fn wear_report(samples: &[Vec; 2]) -> (String, Vec) { wf.high_good, wf.high_used, json::num(wf.fixed), + json::num(wf.rose_from), )); lines.push(format!( - "calib wear {}: widen step {:.3} (needs >= {MIN_WIDEN_STEP}; normal {:.3}, widen {:.3}), open gap {:.3} (needs >= {MIN_OPEN_GAP}), \ -frames with pupil normal {n_pupil} of {}, with the upper lid line {nn} / widen {nw} (need {} / {}), close {nc} (need {}; at {:.0} fps), widen below normal left out {below}, widen from {} (no line near normal {} of {}, upper good {} / used {}), R {:.1} px; {}", + "calib wear {}: widen step {:.3} (needs >= {MIN_WIDEN_STEP}, <= {MAX_WIDEN_STEP}; normal {:.3}, widen {:.3}), open gap {:.3} (needs >= {MIN_OPEN_GAP}), \ +frames with pupil normal {n_pupil} of {}, with the upper lid line {nn} / widen {nw} (need {} / {}), close {nc} (need {}; at {:.0} fps), widen below normal left out {below}, widen from {} (no line near normal {} of {}, upper good {} / used {}{}), R {:.1} px; {}", ["L", "R"][e], b_w - b_n, b_n, @@ -2624,6 +2658,7 @@ frames with pupil normal {n_pupil} of {}, with the upper lid line {nn} / widen { wf.with_pupil, wf.high_good, wf.high_used, + if wf.rose_from.is_nan() { String::new() } else { format!(", rose from {:.3}", wf.rose_from) }, r_px, d.line )); @@ -2631,7 +2666,7 @@ frames with pupil normal {n_pupil} of {}, with the upper lid line {nn} / widen { // The frame counts needed per eye ([L, R]), for the frame rate each eye's camera delivered, and the rule. let pair = |f: &dyn Fn(&WearNeeds) -> String| format!("[{}, {}]", f(&needs[0]), f(&needs[1])); let json = format!( - "{{{}, \"thresholds\": {{\"widen_step_min\": {MIN_WIDEN_STEP}, \"open_gap_min\": {MIN_OPEN_GAP}, \"fps\": {}, \"normal_frames_min\": {}, \ + "{{{}, \"thresholds\": {{\"widen_step_min\": {MIN_WIDEN_STEP}, \"widen_step_max\": {MAX_WIDEN_STEP}, \"open_gap_min\": {MIN_OPEN_GAP}, \"fps\": {}, \"normal_frames_min\": {}, \ \"widen_frames_min\": {}, \"close_frames_min\": {}, \"rule\": {{\"normal\": {}, \"widen\": {}, \"close\": {}}}}}}}", eyes.join(", "), pair(&|n| json::num(n.fps)), @@ -2987,16 +3022,17 @@ mod tests { #[test] fn a_widened_lid_above_the_band_is_measured_in_the_higher_band() { // A user's 0.7.9 wear calibration (2026-10-17): during the widen the lid line never entered the band; the - // search found its bottom edge (now no line) or the floor (-0.3). The higher band has the lid at 1.9. - let mut widen = widen_high(250, 1.9, 0.85); + // search found its bottom edge (now no line) or the floor (-0.3). The higher band has the lid at 1.8 + // (that wearer's 1.9 is a line other than the lid's edge, and a step above MAX_WIDEN_STEP is not a widen). + let mut widen = widen_high(250, 1.8, 0.85); widen.extend(samples(Label::Widen, 100, -0.3, 2.4, -14.0, 252.0, true)); let eye = wear_widen(widen); let wf = widen_fit(&eye, 1.0, 60); assert_eq!((wf.source, wf.with_pupil, wf.low, wf.high_good, wf.high_used), (WidenSource::Upper, 350, 350, 250, 250)); - assert!((wf.b_w - 1.91).abs() < 1e-9, "{wf:?}"); + assert!((wf.b_w - 1.81).abs() < 1e-9, "{wf:?}"); let both = [eye.clone(), eye]; let (w, measured) = fit_wear(&both, [0.19; 2]).unwrap(); - assert!(measured && (w[0].b_w - 1.91).abs() < 1e-9, "{:?}", w[0]); + assert!(measured && (w[0].b_w - 1.81).abs() < 1e-9, "{:?}", w[0]); let (report, lines) = wear_report(&both); assert!(report.contains("\"widen_source\": \"upper\"") && report.contains("\"widen_frames_upper_used\": 250"), "{report}"); assert!(report.contains("\"widen_frames_below_normal\": 100"), "the existing key counts as before: {report}"); @@ -3004,7 +3040,7 @@ mod tests { // The CSV has the new columns: this frame's line share, the edge flag, the higher band's line. let csv = samples_csv(&both); assert!(csv.lines().next().unwrap().ends_with(",prior_reset,upper_frac,lid_edge,skin_up2,upper_frac2")); - assert!(csv.lines().any(|l| l.starts_with("L,") && l.contains(",widen,") && l.ends_with(",1.00000,1,1.90000,0.85000")), "{csv}"); + assert!(csv.lines().any(|l| l.starts_with("L,") && l.contains(",widen,") && l.ends_with(",1.00000,1,1.80000,0.85000")), "{csv}"); } #[test] @@ -3069,6 +3105,73 @@ mod tests { assert_eq!(widen_fit(&eye, 1.0, 60).source, WidenSource::Upper); } + #[test] + fn widen_lines_above_the_higher_bands_normal_line_give_its_rise() { + // A tester with lens inserts (a recording of 2026-10-23): normal level 0.73 (the lid's edge), and the higher + // band finds the tips of the upper lashes in both steps, 1.69 in the normal frames (weaker lines, share 0.64) + // and 2.0 in the widen ones. The step is their rise (0.31), not 2.0 - 0.73. + let mut eye = samples(Label::Normal, 300, 0.73, 1.7, -14.0, 250.0, true); + for x in eye.iter_mut() { + (x.skin2, x.frac2) = (1.69, 0.64); + } + eye.extend(widen_high(250, 2.0, 0.85)); + eye.extend(samples(Label::Close, 80, f64::NAN, 0.56, -14.0, 240.0, false)); + let wf = widen_fit(&eye, 0.73, 60); + assert_eq!(wf.source, WidenSource::Upper); + assert!(wf.fixed.is_nan() && (wf.rose_from - 1.69).abs() < 1e-9, "{wf:?}"); + assert!((wf.b_w - (0.73 + 2.01 - 1.69)).abs() < 1e-9, "{wf:?}"); + let both = [eye.clone(), eye.clone()]; + let (w, measured) = fit_wear(&both, [0.19; 2]).unwrap(); + assert!(measured && (w[0].b_w - w[0].b_n - 0.32).abs() < 1e-9, "{:?}", w[0]); + let (report, lines) = wear_report(&both); + assert!(report.contains("\"widen_upper_rose_from\": 1.69") && report.contains("\"widen_step_max\": 0.9"), "{report}"); + assert!(lines[0].contains("upper good 250 / used 250, rose from 1.690)"), "{}", lines[0]); + // In too few normal frames (under WIDEN_HIGH_FIXED_SHARE), or as weak as another line the search settled on, + // it is not a level to rise from: the widen level is the higher band's own, as before. + let mut few = eye.clone(); + for x in few.iter_mut().filter(|x| x.label == Label::Normal).skip(50) { + (x.skin2, x.frac2) = (f64::NAN, f64::NAN); + } + assert!((widen_fit(&few, 0.73, 60).b_w - 2.01).abs() < 1e-9); + let mut weak = eye.clone(); + for x in weak.iter_mut().filter(|x| x.label == Label::Normal) { + x.frac2 = 0.45; + } + assert!(widen_fit(&weak, 0.73, 60).rose_from.is_nan()); + // A widen line below the normal frames' line (a crease or brow up there) is the lid itself: as before. + let mut crease = eye.clone(); + for x in crease.iter_mut().filter(|x| x.label == Label::Normal) { + x.skin2 = 2.3; + } + let wf = widen_fit(&crease, 0.73, 60); + assert!(wf.rose_from.is_nan() && (wf.b_w - 2.01).abs() < 1e-9, "{wf:?}"); + } + + #[test] + fn a_widen_step_above_the_largest_is_not_a_widen() { + // The same tester's 0.8.2-beta.1 calibration measured 1.165 / 1.054: two different lines. The eye goes + // through with the usual step, as for a widen not caught, and the step stays out of the history. + let eye = wear_eye_with_widen(&[(250, 2.0)]); + let (w, measured) = fit_wear(&[eye.clone(), eye.clone()], [0.25; 2]).unwrap(); + assert!(!measured && (w[0].b_w - w[0].b_n - 0.25).abs() < 1e-9, "{:?}", w[0]); + let out = fit_wear_settled(&[eye.clone(), eye], [0.25; 2], [None; 2], None).unwrap(); + assert_eq!(out.message, "校正できた(見開きは取れなかったので、いつもの幅を使うよ)"); + assert!(!out.for_history()); + // Up to MAX_WIDEN_STEP it is measured. + let eye = wear_eye_with_widen(&[(250, 1.0 + MAX_WIDEN_STEP)]); + assert!(fit_wear(&[eye.clone(), eye], [0.25; 2]).unwrap().1); + // Steps above it already in a history (saved by 0.8.2-beta.1) are left out of its median; with none + // other, the default. + let rec = |step| WearRecord { step, gap: 1.0, r_px: 55.0 }; + let mut file = CalibFile::default(); + for (i, s) in [0.3, 1.165, 1.2, 0.25, 1.1].into_iter().enumerate() { + file.history.push((format!("t{i}"), [rec(s), rec(s)])); + } + assert!((file.history_params().unwrap()[0].step - 0.275).abs() < 1e-9); + file.history.retain(|h| h.1[0].step > 1.0); + assert_eq!(file.history_params().unwrap()[0].step, DEFAULT_WIDEN_STEP); + } + #[test] fn fits_wear_and_user_and_round_trips_json() { let mut eye = Vec::new(); From d88758d53b05240b174313ec64489cd64dbb45a3 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sat, 24 Oct 2026 16:16:28 +0900 Subject: [PATCH 24/65] =?UTF-8?q?eyecam:=20=E7=9E=B3=E3=81=8C=E4=B8=B8?= =?UTF-8?q?=E3=81=94=E3=81=A8=E8=A6=8B=E3=81=88=E3=81=A6=E3=81=84=E3=82=8B?= =?UTF-8?q?=E3=81=AE=E3=81=AB=E4=B8=8A=E3=81=BE=E3=81=B6=E3=81=9F=E3=81=AE?= =?UTF-8?q?=E7=B7=9A=E3=81=8C=E7=9E=B3=E3=81=AE=E4=B8=8A=E7=AB=AF=E3=82=88?= =?UTF-8?q?=E3=82=8A=E4=B8=8B=E3=81=AB=E5=87=BA=E3=81=9F=E3=82=89=E3=80=81?= =?UTF-8?q?=E7=9E=B3=E3=81=AE=E4=B8=8A=E3=81=A7=E3=82=82=E3=81=86=E4=B8=80?= =?UTF-8?q?=E5=BA=A6=E6=8E=A2=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 度付きインサートの人の録画(2026-10-23): 白目が明るく照らされ、インサートの映り込みとまつ毛が長いせいで、 上まぶたの線が普段の段で 76 % / 77 %、見開きで 86 % / 96 % のフレームで瞳の中心より下に出て(線なし)、 残りもほとんど中心のすぐ上(0.05 R)。ライブでは見開いた目がほぼ閉じかけに読まれていた - 瞳が丸ごと見えている(光線の 75 % 以上)フレームで、線が瞳の上端(cy - b)より下なら、探す範囲の上端から 瞳の上 2 px までで探し直し、端に張り付いていない線があればそれを上まぶたの線にする。瞳がないフレームは今までどおり - この録画の校正: 普段の段で線が取れたフレーム 368 / 350 → 1419 / 1504(1512 中)、見開きの幅 0.52 / 0.50(main から) - ライブ(この録画でユーザー校正を当てた replay): 見開きの段で EyeWide > 0.5 が左 35/0/0 % → 76/22/19 %、 右 0/0/0 % → 89/93/90 %。普段の段の細め > 0.5 の誤読も消えた - 開発者の録画 19 本(約 40 万行): skin_up が変わるのは 0.04〜0.10 %、出力の目の値の変化は 1 本で EyeWide 4 フレームだけ。 dev 校正 20 回は前と同じ値 Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/src/feat.rs | 51 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/tools/eyecam/src/feat.rs b/tools/eyecam/src/feat.rs index a2b2f18..16115bc 100644 --- a/tools/eyecam/src/feat.rs +++ b/tools/eyecam/src/feat.rs @@ -77,6 +77,17 @@ const LID_EDGE_ROWS: usize = 1; /// reach its edge: a lid that low hides its upper half): the same wearer's left eye had its upper line at -0.25 r, under /// the centre, on more than half of its widen frames; the developer's recordings, 0.001% of such frames. const UPPER_WHOLE_VIS: f64 = 0.75; +/// With the pupil seen whole, a line found below its top is searched for again above it (rows from the band's top to +/// this many px above the pupil), and the line found there, if any (not pushed against that band's edge), is the +/// upper lid's: the lid cannot be that low while the whole pupil shows, and a stronger bright -> dark step lower down +/// took the search there. A tester with lens inserts (a recording of 2026-10-23, a brightly lit sclera and glints from +/// the inserts, long upper lashes) had the line under the pupil's centre in 76% / 77% of the normal frames and 86% / +/// 96% of the widen frames, and most of the rest just above the centre: live read the widened eye as nearly shut +/// (skin line 0.05 R), and its wear calibration had a line in 368 / 350 of 1512 normal frames. Searched above the +/// pupil, the line is the lid's edge in the normal frames (0.82 / 0.78, in 1419 / 1504 of them) and the lid's edge or +/// the iris's top when widened (1.34 / 1.29): a widen step of 0.52 / 0.50, and in live the widen steps read as wide +/// (EyeWide over 0.5) in 76 / 22 / 19% and 89 / 93 / 90% of their frames instead of 35 / 0 / 0% and 0 / 0 / 0%. +const ABOVE_PUPIL_PX: f64 = 2.0; /// The calibration's second, higher search band for a widened upper lid (`Extractor::upper_high`): rows from /// cy - HIGH_BAND.0 r to cy - HIGH_BAND.1 r (the usual band is cy - 2.1 r .. cy + 0.45 r). See live::widen_fit. pub const HIGH_BAND: (f64, f64) = (3.2, 1.6); @@ -634,7 +645,7 @@ impl Extractor { let Some(q) = prev_p else { return (f, None) }; let rn = r_ref.unwrap_or(2.0 * q.a); f.box_mean = self.box_mean(q.cx, q.cy, rn, xmax); - let (upper, lower, margin) = self.lids(q, rn, xmax); + let (upper, lower, margin) = self.lids(q, rn, xmax, false); if let Some(u) = upper { f.upper_skin_y = u.y_at; f.upper_y = u.y_at + margin.unwrap_or(MARGIN_OFF); @@ -656,7 +667,7 @@ impl Extractor { f.iris_r = r; f.iris_n = n; let rn = r_ref.unwrap_or(if r.is_finite() { r } else { 2.0 * p.a }); - let (upper, lower, margin) = self.lids(&p, rn, xmax); + let (upper, lower, margin) = self.lids(&p, rn, xmax, p.vis >= UPPER_WHOLE_VIS); let no_line = |u: &Lid| u.on_edge() || (p.vis >= UPPER_WHOLE_VIS && u.y_at > p.cy); f.upper_edge = upper.is_some_and(|u| no_line(&u)); if let Some(u) = upper.filter(|u| !no_line(u)) { @@ -990,8 +1001,9 @@ impl Extractor { } /// Upper lid skin line (bright skin -> dark going down), its margin offset, and the lower lid (dark -> bright), - /// over the iris columns with the pupil masked (feat2.lids, base variant). - fn lids(&mut self, p: &Pupil, r: f64, xmax: f64) -> (Option, Option, Option) { + /// over the iris columns with the pupil masked (feat2.lids, base variant). `whole`: the pupil `p` is this frame's + /// and seen whole (ABOVE_PUPIL_PX). + fn lids(&mut self, p: &Pupil, r: f64, xmax: f64, whole: bool) -> (Option, Option, Option) { self.lid_r = r; let cy = p.cy; let (xa, xb) = self.lid_cols(p, r, xmax); @@ -1016,7 +1028,13 @@ impl Extractor { }; *slot = self.lid_line(p, (xa, xb), (ya, yb), gy_ok, which == 0); } - let [upper, lower] = out; + let [mut upper, lower] = out; + if whole && upper.is_some_and(|u| !u.on_edge() && u.y_at > cy - p.b) { + let band = (pint((cy - 2.1 * r).max(4.0)), pint(cy - p.b - ABOVE_PUPIL_PX)); + if let Some(u) = self.lid_line(p, (xa, xb), band, gy_ok, true).filter(|u| !u.on_edge()) { + upper = Some(u); + } + } let margin = upper.and_then(|u| self.margin(p, &u, xa, xb, gy_ok)); (upper, lower, margin) } @@ -1398,6 +1416,29 @@ mod tests { assert!(!f.upper_edge && (f.upper_skin_y - 180.0).abs() < 3.0, "{f:?}"); } + #[test] + fn a_line_below_a_whole_pupils_top_is_searched_for_again_above_it() { + let mut ex = Extractor::default(); + // A strong bright -> dark step 20 px below the centre (a brightly lit white of the eye over the lower lid) and a + // weaker one 50 px above it (the lid): the line is the one above the pupil. + let mut img = eye_with_step_r(220, 12.0); + for y in 0..150 { + for x in 0..350 { + if img[y * W + x] == 150 { + img[y * W + x] = 170; + } + } + } + let (f, p) = ex.extract(&img, 347.0, None, Some(55.0), None); + assert!(p.expect("pupil").vis >= UPPER_WHOLE_VIS); + assert!(!f.upper_edge && (f.upper_skin_y - 150.0).abs() < 3.0, "{f:?}"); + // Without a pupil this frame (measured where the last one was) there is no second search. + let pupil = p.unwrap(); + let blank = img.iter().map(|&v| if v == 12 { 150 } else { v }).collect::>(); + let (f, p) = ex.extract(&blank, 347.0, Some((250.0, 200.0)), Some(55.0), Some(&pupil)); + assert!(p.is_none() && (f.upper_skin_y - 220.0).abs() < 3.0, "{f:?}"); + } + /// Pupil (12, radius 18) at (230, 200) in an iris (70, radius 45) on sclera (120), a ring of brighter iris (85) at /// radius 28..34, the lens edge band from x 350, and from x `band` on a band of `level` (not saturated). fn eye_with_band(band: usize, level: u8) -> Vec { From 32e3b6726bf204998139c3fb636f608b3b61713f Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sat, 24 Oct 2026 16:39:43 +0900 Subject: [PATCH 25/65] =?UTF-8?q?eyecam:=20=E8=A6=8B=E9=96=8B=E3=81=8D?= =?UTF-8?q?=E3=81=AE=E5=B9=85=E3=81=AE=E4=B8=8A=E9=99=90=E3=82=92=201.1=20?= =?UTF-8?q?=E3=81=AB=E3=81=97=EF=BC=88=E5=B1=A5=E6=AD=B4=E3=81=AE=E4=B8=AD?= =?UTF-8?q?=E5=A4=AE=E5=80=A4=E3=81=8B=E3=82=89=E9=99=A4=E3=81=8F=E3=81=AE?= =?UTF-8?q?=E3=81=AF=200.9=20=E3=81=AE=E3=81=BE=E3=81=BE=EF=BC=89=E3=80=81?= =?UTF-8?q?=E7=A2=BA=E3=81=8B=E3=82=81=E3=81=9F=E3=81=93=E3=81=A8=E3=81=A0?= =?UTF-8?q?=E3=81=91=E3=82=92=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=81=AB?= =?UTF-8?q?=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - チェック担当の指摘: 同じ人(2026-10-14 / 10-17 の校正とガイド撮影の人。度付きインサートのテスター)の 10-17 の校正は 普段の範囲で 0.83〜0.87、ガイド撮影は 0.80 / 0.90 を測っていて、0.9 の上限だと通らないことがある - MAX_WIDEN_STEP 1.1(校正で見開きとして通す上限)と HISTORY_MAX_WIDEN_STEP 0.9(履歴の中央値から除く)に分ける - 10-17 の calib_samples.csv(0.7.9、skin_up2 なし)をこのコードで当てはめ直すと 0.7.9 と同じ (b_n 0.875 / 0.705、見開きは取れずいつもの幅)。ガイド撮影の特徴量では幅 0.795 / 0.900 で通る(b_w 1.78 / 1.88) - 「1.9 はまぶたの縁ではない」という根拠のない一文を消し、テストを 1.9 / 1.91 に戻す。 WIDEN_HIGH_REF_FRAC のコメントは 2026-10-23 の録画の画像で確かめたことと、しわが見開いたまぶたの縁より下に あれば幅が小さく出る(その録画はない)ことだけにする Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/README.md | 5 ++-- tools/eyecam/src/live.rs | 64 ++++++++++++++++++++++------------------ 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index 659bd09..e31010d 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -576,8 +576,9 @@ python3 ~/eyecam-src/tools/drift_report.py ~/eyecam/drift/drift_….csv # 5 「。カメラの映像が毎秒 15 枚しか届いていない」が付く(英語 ". The eye cameras deliver only 15 frames a second")。 calib_result.json の `thresholds` に目ごと `[左, 右]` の `fps` と要った数(`normal_frames_min` など)、決め方(`rule`)が入る - `calib user` は、同じかぶりで `calib wear` が済んでいないと断る。視線は Valve の値を使うので、frameeyeosc が動いている必要がある -- **`calib wear` で閉じる・普段は取れたのに見開きが取れなかったとき(見開きのフレームが足りない、幅が 0.05 未満か 0.9 より大きい)は、失敗にしない**。 - 0.9 より大きい幅は見開きではなく、普段と見開きで別の線を取ったもの(開発者の 47 回で 0.15〜0.43、度付きインサートの人の 0.8.2-beta.1 で 1.165 / 1.054)。 +- **`calib wear` で閉じる・普段は取れたのに見開きが取れなかったとき(見開きのフレームが足りない、幅が 0.05 未満か 1.1 より大きい)は、失敗にしない**。 + 1.1 より大きい幅は、普段と見開きで別の線を取ったものとみる(開発者の 47 回で 0.15〜0.43。度付きインサートの人の 0.8.2-beta.1 で 1.165 / 1.054、 + その人の 2026-10-23 の録画では普段がまぶたの縁、見開きがまつ毛の先だった。同じ人の 2026-10-17 の校正で普段の範囲から測った 0.83〜0.87 は通す)。 普段の開き・閉じた開きはこの校正の値を使い、見開きの幅は履歴の中央値(0.9 より大きい幅は除く。なければ既定の 0.19)。この回の幅は履歴に入れない。 `message` は「校正できた(見開きは取れなかったので、いつもの幅を使うよ)」、`last_calib_widen` は `default`、`calib_state` の 1 は立つ - **`calib wear` で片目だけ失敗したとき(瞳が見えない・上まぶたの線が取れない・閉じが検出できない)も、失敗にしない**。 diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index 90c03e7..f9100a9 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -2156,14 +2156,14 @@ impl CalibFile { [0, 1].map(|e| self.wear.map(|w| w[e]).filter(|_| self.wear_failed_eye != ["L", "R"][e])) } - /// The medians of the history (None when it is empty). Widen steps above MAX_WIDEN_STEP (saved before that check) - /// are left out of the step's median; with none other left it is DEFAULT_WIDEN_STEP. + /// The medians of the history (None when it is empty). Widen steps above HISTORY_MAX_WIDEN_STEP are left out of + /// the step's median; with none other left it is DEFAULT_WIDEN_STEP. pub fn history_params(&self) -> Option<[History; 2]> { if self.history.is_empty() { return None; } let step = |e: usize| { - let m = median_of(self.history.iter().map(|x| x.1[e].step).filter(|s| *s <= MAX_WIDEN_STEP)); + let m = median_of(self.history.iter().map(|x| x.1[e].step).filter(|s| *s <= HISTORY_MAX_WIDEN_STEP)); if m.is_finite() { m } else { DEFAULT_WIDEN_STEP } }; let h = |e: usize| History { @@ -2322,12 +2322,18 @@ impl CalibFile { /// Thresholds of the calibration checks (fit_wear / fit_user use the same numbers). pub const MIN_WIDEN_STEP: f64 = 0.05; -/// A widen step above this (iris radii) is not a widen: a lid's edge does not rise that far above where it sits -/// looking ahead, so two different lines were taken for the normal and the widen level. The eye gets the usual step -/// (as with a widen not caught), and such a step is not taken into the history's median either. The developer's -/// 47 wear calibrations: 0.15 to 0.43. A tester with lens inserts (0.8.2-beta.1): 1.165 / 1.054, the usual band's -/// normal level against the tips of the upper lashes in the higher band (see WIDEN_HIGH_REF_FRAC). -pub const MAX_WIDEN_STEP: f64 = 0.9; +/// A widen step above this (iris radii) is taken as two different lines for the normal and the widen level, not a +/// widen: the eye gets the usual step (as with a widen not caught), and the step stays out of the history. The +/// developer's 47 wear calibrations: 0.15 to 0.43. A tester with lens inserts: 1.165 / 1.054 in a 0.8.2-beta.1 wear +/// calibration, and 1.30 / 1.15 replaying a recording of 2026-10-23, where the pictures show the usual band's +/// normal level on the lid's edge and the higher band's widen level on the tips of the upper lashes (see +/// WIDEN_HIGH_REF_FRAC). Left above the steps of 0.83 to 0.87 that the same tester's wear calibration of 2026-10-17 +/// measured in the usual band (no pictures of it to tell which line that was). +pub const MAX_WIDEN_STEP: f64 = 1.1; +/// Widen steps above this are left out of the history's median (the usual step of a widen not caught): the +/// developer's go up to 0.43, and a step this large in a history saved before MAX_WIDEN_STEP is more likely the two +/// lines above than a widen. +pub const HISTORY_MAX_WIDEN_STEP: f64 = 0.9; pub const MIN_OPEN_GAP: f64 = 0.2; /// A widen frame whose upper lid line lies more than this (iris radii) below the normal level is left out of the /// widen level: a widened lid is never lower than the normal one, so such a line is not the lid. Some wearers' lid @@ -2366,13 +2372,15 @@ pub const WIDEN_HIGH_NEIGHBOUR: f64 = 0.1; pub const WIDEN_HIGH_FIXED: f64 = 0.15; pub const WIDEN_HIGH_FIXED_SHARE: f64 = 0.25; /// When the higher band finds a line in at least WIDEN_HIGH_FIXED_SHARE of the normal frames with the pupil (inlier -/// share at least this), and the widen lines lie above that line's median, they are the same line moved up with the -/// widening lid, not the lid's edge: the widen step is how far that line rose, added to the normal level (the usual -/// band's). A tester with lens inserts (0.8.2-beta.1, a recording of 2026-10-23): the usual band's normal level is the -/// lid's edge (0.73 / 0.68 R), while the higher band sees the tips of the long upper lashes in both steps (normal -/// 1.69 / 1.67, widen 2.01 / 1.88). Measured from the normal level the step came out 1.30 / 1.15, so the widen could -/// never be reached; the lashes' rise gives 0.3 / 0.2. Their normal lines are weaker than the widen ones (median -/// share 0.64 on the right eye), hence a lower bar than WIDEN_HIGH_MIN_FRAC. +/// share at least this), and the widen lines lie above that line's median, the widen lines are taken as the same line +/// moved up with the widening lid: the widen step is how far it rose, added to the normal level (the usual band's). +/// A tester with lens inserts (a recording of 2026-10-23, both eyes): the pictures show the usual band's normal level +/// (0.73 / 0.68 R) on the lid's edge and the higher band's line on the tips of the long upper lashes in both steps +/// (normal 1.69 / 1.67, widen 2.01 / 1.88), the widened lid's edge below them at about 1.4 to 1.5 R. From the normal +/// level the step came out 1.30 / 1.15 and the widen was never reached; the lashes' rise gives 0.33 / 0.23. The +/// normal lines were weaker than the widen ones (median share 0.64 on the right eye), hence a lower bar than +/// WIDEN_HIGH_MIN_FRAC. If a line in the normal frames were a crease below a widened lid's edge, the step would come +/// out too small; no recording here shows that. pub const WIDEN_HIGH_REF_FRAC: f64 = 0.5; /// Where a wear calibration's widen level came from (calib_result.json's `widen_source`). @@ -3022,17 +3030,16 @@ mod tests { #[test] fn a_widened_lid_above_the_band_is_measured_in_the_higher_band() { // A user's 0.7.9 wear calibration (2026-10-17): during the widen the lid line never entered the band; the - // search found its bottom edge (now no line) or the floor (-0.3). The higher band has the lid at 1.8 - // (that wearer's 1.9 is a line other than the lid's edge, and a step above MAX_WIDEN_STEP is not a widen). - let mut widen = widen_high(250, 1.8, 0.85); + // search found its bottom edge (now no line) or the floor (-0.3). The higher band has the lid at 1.9. + let mut widen = widen_high(250, 1.9, 0.85); widen.extend(samples(Label::Widen, 100, -0.3, 2.4, -14.0, 252.0, true)); let eye = wear_widen(widen); let wf = widen_fit(&eye, 1.0, 60); assert_eq!((wf.source, wf.with_pupil, wf.low, wf.high_good, wf.high_used), (WidenSource::Upper, 350, 350, 250, 250)); - assert!((wf.b_w - 1.81).abs() < 1e-9, "{wf:?}"); + assert!((wf.b_w - 1.91).abs() < 1e-9, "{wf:?}"); let both = [eye.clone(), eye]; let (w, measured) = fit_wear(&both, [0.19; 2]).unwrap(); - assert!(measured && (w[0].b_w - 1.81).abs() < 1e-9, "{:?}", w[0]); + assert!(measured && (w[0].b_w - 1.91).abs() < 1e-9, "{:?}", w[0]); let (report, lines) = wear_report(&both); assert!(report.contains("\"widen_source\": \"upper\"") && report.contains("\"widen_frames_upper_used\": 250"), "{report}"); assert!(report.contains("\"widen_frames_below_normal\": 100"), "the existing key counts as before: {report}"); @@ -3040,7 +3047,7 @@ mod tests { // The CSV has the new columns: this frame's line share, the edge flag, the higher band's line. let csv = samples_csv(&both); assert!(csv.lines().next().unwrap().ends_with(",prior_reset,upper_frac,lid_edge,skin_up2,upper_frac2")); - assert!(csv.lines().any(|l| l.starts_with("L,") && l.contains(",widen,") && l.ends_with(",1.00000,1,1.80000,0.85000")), "{csv}"); + assert!(csv.lines().any(|l| l.starts_with("L,") && l.contains(",widen,") && l.ends_with(",1.00000,1,1.90000,0.85000")), "{csv}"); } #[test] @@ -3124,7 +3131,7 @@ mod tests { let (w, measured) = fit_wear(&both, [0.19; 2]).unwrap(); assert!(measured && (w[0].b_w - w[0].b_n - 0.32).abs() < 1e-9, "{:?}", w[0]); let (report, lines) = wear_report(&both); - assert!(report.contains("\"widen_upper_rose_from\": 1.69") && report.contains("\"widen_step_max\": 0.9"), "{report}"); + assert!(report.contains("\"widen_upper_rose_from\": 1.69") && report.contains("\"widen_step_max\": 1.1"), "{report}"); assert!(lines[0].contains("upper good 250 / used 250, rose from 1.690)"), "{}", lines[0]); // In too few normal frames (under WIDEN_HIGH_FIXED_SHARE), or as weak as another line the search settled on, // it is not a level to rise from: the widen level is the higher band's own, as before. @@ -3149,9 +3156,10 @@ mod tests { #[test] fn a_widen_step_above_the_largest_is_not_a_widen() { - // The same tester's 0.8.2-beta.1 calibration measured 1.165 / 1.054: two different lines. The eye goes - // through with the usual step, as for a widen not caught, and the step stays out of the history. - let eye = wear_eye_with_widen(&[(250, 2.0)]); + // Two different lines for the normal and the widen level (the same tester's recording of 2026-10-23 + // replayed before WIDEN_HIGH_REF_FRAC: 1.30 / 1.15). The eye goes through with the usual step, as for a + // widen not caught, and the step stays out of the history. + let eye = wear_eye_with_widen(&[(250, 2.2)]); let (w, measured) = fit_wear(&[eye.clone(), eye.clone()], [0.25; 2]).unwrap(); assert!(!measured && (w[0].b_w - w[0].b_n - 0.25).abs() < 1e-9, "{:?}", w[0]); let out = fit_wear_settled(&[eye.clone(), eye], [0.25; 2], [None; 2], None).unwrap(); @@ -3160,8 +3168,8 @@ mod tests { // Up to MAX_WIDEN_STEP it is measured. let eye = wear_eye_with_widen(&[(250, 1.0 + MAX_WIDEN_STEP)]); assert!(fit_wear(&[eye.clone(), eye], [0.25; 2]).unwrap().1); - // Steps above it already in a history (saved by 0.8.2-beta.1) are left out of its median; with none - // other, the default. + // Steps above HISTORY_MAX_WIDEN_STEP in a history (0.8.2-beta.1 saved 1.165 / 1.054) are left out of its + // median; with none other, the default. let rec = |step| WearRecord { step, gap: 1.0, r_px: 55.0 }; let mut file = CalibFile::default(); for (i, s) in [0.3, 1.165, 1.2, 0.25, 1.1].into_iter().enumerate() { From 09ce02bd036801ffffa566d2fab2f873137b8351 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sat, 24 Oct 2026 16:39:43 +0900 Subject: [PATCH 26/65] =?UTF-8?q?eyecam:=20=E4=B8=8A=E3=81=BE=E3=81=B6?= =?UTF-8?q?=E3=81=9F=E3=81=AE=E7=B7=9A=E3=82=92=E6=8E=A2=E3=81=97=E7=9B=B4?= =?UTF-8?q?=E3=81=99=E3=81=AE=E3=81=AF=E7=9E=B3=E3=81=AE=E7=9F=AD=E5=8D=8A?= =?UTF-8?q?=E5=BE=84=E3=81=AE=E5=8D=8A=E5=88=86=E3=82=88=E3=82=8A=E4=B8=8B?= =?UTF-8?q?=E3=81=AE=E3=81=A8=E3=81=8D=E3=81=A0=E3=81=91=E3=81=AB=E3=81=97?= =?UTF-8?q?=E3=80=81=E5=85=83=E3=81=AE=E7=B7=9A=E3=82=88=E3=82=8A=20inlier?= =?UTF-8?q?=20=E3=81=AE=E5=89=B2=E5=90=88=E3=81=8C=E4=BD=8E=E3=81=84?= =?UTF-8?q?=E7=B7=9A=E3=81=AB=E3=81=AF=E6=9B=BF=E3=81=88=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - チェック担当の指摘: 瞳の上端をかすめるだけのまぶた(上の 15〜20 % が隠れても光線の 75 % 以上は届く)でも 探し直しが走り、まぶたの縁が探し直す範囲(瞳の上 2 px まで)の外にあるので、上のしわやまつ毛を取って開きすぎに読む - 条件を y > cy - b から y > cy - 0.5 b にし、探し直した線は inlier の割合が元の線以上のときだけ使う - テスト: 瞳の中心から 8 px 上(上端と半分の間)のまぶたと上の弱いしわ → まぶたのまま。前の条件だと 149 のしわを取る - 度付きインサートの人の録画: 校正の見開きの幅 0.515 / 0.505、普段の段の線 1419 / 1501(1512 中)。 ライブの見開きの段は前のコミットと同じ(左 76/22/19 %、右 89/93/90 %) - 開発者: dev 校正 20 回は同じ値、ライブ replay 19 本は 18 本が同じで、1 本だけ 50 行(0.16 %、EyeWide の反転 4 フレーム) Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/eyecam/src/feat.rs | 45 +++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/tools/eyecam/src/feat.rs b/tools/eyecam/src/feat.rs index 16115bc..38e20e7 100644 --- a/tools/eyecam/src/feat.rs +++ b/tools/eyecam/src/feat.rs @@ -77,17 +77,19 @@ const LID_EDGE_ROWS: usize = 1; /// reach its edge: a lid that low hides its upper half): the same wearer's left eye had its upper line at -0.25 r, under /// the centre, on more than half of its widen frames; the developer's recordings, 0.001% of such frames. const UPPER_WHOLE_VIS: f64 = 0.75; -/// With the pupil seen whole, a line found below its top is searched for again above it (rows from the band's top to -/// this many px above the pupil), and the line found there, if any (not pushed against that band's edge), is the -/// upper lid's: the lid cannot be that low while the whole pupil shows, and a stronger bright -> dark step lower down -/// took the search there. A tester with lens inserts (a recording of 2026-10-23, a brightly lit sclera and glints from -/// the inserts, long upper lashes) had the line under the pupil's centre in 76% / 77% of the normal frames and 86% / -/// 96% of the widen frames, and most of the rest just above the centre: live read the widened eye as nearly shut -/// (skin line 0.05 R), and its wear calibration had a line in 368 / 350 of 1512 normal frames. Searched above the -/// pupil, the line is the lid's edge in the normal frames (0.82 / 0.78, in 1419 / 1504 of them) and the lid's edge or -/// the iris's top when widened (1.34 / 1.29): a widen step of 0.52 / 0.50, and in live the widen steps read as wide -/// (EyeWide over 0.5) in 76 / 22 / 19% and 89 / 93 / 90% of their frames instead of 35 / 0 / 0% and 0 / 0 / 0%. +/// With the pupil seen whole, a line found lower than ABOVE_PUPIL_B of its minor semi-axis above its centre is +/// searched for again above the pupil (rows from the band's top to ABOVE_PUPIL_PX px above the pupil), and the line +/// found there replaces it if it is not pushed against that band's edge and its inlier share is at least the first +/// one's: a stronger bright -> dark step lower down took the search there. A lid that only grazes the pupil's top +/// (vis still over UPPER_WHOLE_VIS) sits above ABOVE_PUPIL_B and keeps its line; its edge would be outside the second +/// band, which could then take a crease or the lashes above. A tester with lens inserts (a recording of 2026-10-23, a +/// brightly lit sclera and glints from the inserts, long upper lashes) had the line under the pupil's centre in 76% / +/// 77% of the normal frames and 86% / 96% of the widen frames, and most of the rest just above the centre: live read +/// the widened eye as nearly shut (skin line 0.05 R), and its wear calibration had a line in 368 / 350 of 1512 normal +/// frames. Searched above the pupil, the line is on the lid's edge in the normal frames (0.83 / 0.76, in 1419 / 1501 +/// of them) and on the lid's edge or the iris's top when widened (1.35 / 1.26): a widen step of 0.52 / 0.51. const ABOVE_PUPIL_PX: f64 = 2.0; +const ABOVE_PUPIL_B: f64 = 0.5; /// The calibration's second, higher search band for a widened upper lid (`Extractor::upper_high`): rows from /// cy - HIGH_BAND.0 r to cy - HIGH_BAND.1 r (the usual band is cy - 2.1 r .. cy + 0.45 r). See live::widen_fit. pub const HIGH_BAND: (f64, f64) = (3.2, 1.6); @@ -1029,9 +1031,9 @@ impl Extractor { *slot = self.lid_line(p, (xa, xb), (ya, yb), gy_ok, which == 0); } let [mut upper, lower] = out; - if whole && upper.is_some_and(|u| !u.on_edge() && u.y_at > cy - p.b) { + if whole && let Some(low) = upper.filter(|u| !u.on_edge() && u.y_at > cy - ABOVE_PUPIL_B * p.b) { let band = (pint((cy - 2.1 * r).max(4.0)), pint(cy - p.b - ABOVE_PUPIL_PX)); - if let Some(u) = self.lid_line(p, (xa, xb), band, gy_ok, true).filter(|u| !u.on_edge()) { + if let Some(u) = self.lid_line(p, (xa, xb), band, gy_ok, true).filter(|u| !u.on_edge() && u.frac >= low.frac) { upper = Some(u); } } @@ -1439,6 +1441,25 @@ mod tests { assert!(p.is_none() && (f.upper_skin_y - 220.0).abs() < 3.0, "{f:?}"); } + #[test] + fn a_lid_grazing_a_whole_pupils_top_stays_the_line() { + let mut ex = Extractor::default(); + // The lid's step 8 px above the centre of a pupil of radius 12 (between its top and half way up, as a lid that + // just touches the pupil in a shallow squint), and a weaker crease 50 px above: the lid stays the line. + let mut img = eye_with_step_r(192, 12.0); + for y in 0..150 { + for x in 0..350 { + if img[y * W + x] == 150 { + img[y * W + x] = 165; + } + } + } + let (f, p) = ex.extract(&img, 347.0, None, Some(55.0), None); + let p = p.expect("pupil"); + assert!(p.vis >= UPPER_WHOLE_VIS && 192.0 > p.cy - p.b && 192.0 < p.cy - ABOVE_PUPIL_B * p.b, "{p:?}"); + assert!(!f.upper_edge && (f.upper_skin_y - 192.0).abs() < 3.0, "{f:?}"); + } + /// Pupil (12, radius 18) at (230, 200) in an iris (70, radius 45) on sclera (120), a ring of brighter iris (85) at /// radius 28..34, the lens edge band from x 350, and from x `band` on a band of `level` (not saturated). fn eye_with_band(band: usize, level: u8) -> Vec { From d9eb864a3306269be59bd4c7d3ec4e43ddbf1e64 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sat, 24 Oct 2026 16:42:34 +0900 Subject: [PATCH 27/65] =?UTF-8?q?0.8.2-beta.2:=20=E7=89=88=E3=82=92?= =?UTF-8?q?=E4=B8=8A=E3=81=92=E3=82=8B=EF=BC=88Cargo.toml=E3=83=BBCargo.lo?= =?UTF-8?q?ck=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ef1d8b..154d2a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "frameeyeosc" -version = "0.8.2-beta.1" +version = "0.8.2-beta.2" dependencies = [ "clap", "libc", diff --git a/Cargo.toml b/Cargo.toml index 431e607..db429f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frameeyeosc" -version = "0.8.2-beta.1" +version = "0.8.2-beta.2" edition = "2024" license = "MIT" description = "Sends Steam Frame eye tracking (gaze and eyelids) to VRChat over OSC" From 8e2ea9c83ae5c75c00fc489b73e7741dc9b23898 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sat, 24 Oct 2026 16:42:53 +0900 Subject: [PATCH 28/65] =?UTF-8?q?0.8.2-beta.2:=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=B1=A5=E6=AD=B4=E3=81=AB=E3=80=81=E5=BA=A6=E4=BB=98=E3=81=8D?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=B5=E3=83=BC=E3=83=88=E3=81=A7=E4=B8=8A?= =?UTF-8?q?=E3=81=BE=E3=81=B6=E3=81=9F=E3=81=AE=E7=B7=9A=E3=81=A8=E8=A6=8B?= =?UTF-8?q?=E9=96=8B=E3=81=8D=E3=81=AE=E5=B9=85=E3=82=92=E5=8F=96=E3=82=8A?= =?UTF-8?q?=E9=81=95=E3=81=88=E3=82=8B=E4=BB=B6=E3=81=AE=E7=9B=B4=E3=81=97?= =?UTF-8?q?=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.ja.md | 5 +++-- CHANGELOG.md | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 222bdb7..3ef625e 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -1,8 +1,8 @@ # 更新履歴 -## 0.8.2-beta.1 (2026-10-22) +## 0.8.2-beta.2 (2026-10-24) -SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。 +SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。度付きインサートを使っているときも、目のカメラが上まぶたを見つけやすくなるはずです。見開きが出るようになり、開いた目が細めに読まれることも減ります。 - 視線: どちらかの目の開き具合が `gaze_hold_below`(0.5)より小さい間は、まばたきのために視線を止めています。SteamOS は開いた片目を 0.06 くらいと何分も何時間も読み続けることがあり(度付きインサートのときなど)、そのあいだ、まぶたと瞳孔は動くのに視線だけ止まっていました。今は、片目だけが線より下のときは 1 秒まで(ウインク)止め、それより長いとその目の読みは誤りとみなして止めず、もう片方の目で両目を動かします。まばたきは今までどおり止め、閉じたと読まれ続けている目があっても、まばたきのたびにまた 1 秒止まることはありません。1 サンプルだけ開いたと読まれても、1 秒はやり直しになりません - 新しい設定 `own_eye_gaze`(既定 0、0〜1。`--own-eye-gaze`)。視線 › 動く範囲の「左右の目を別々に動かす」の下の「目ごとの視線」(0〜100 %、5 % 刻み)です。「左右の目を別々に動かす」がオン(v2)のとき、目ごとの左右の視線を、2° 寄せた共通の視線(0 %、今まで通り)から、Frame が追った目ごとの左右の視線そのまま(100 %)のあいだで混ぜます。Frame の目ごとの左右の視線はぶれるので、上げるほどアバターの目がぴくつくことがあります。上下・まぶたなどはそのままで、閉じたと長く読まれている目(上)や視線が当てにならない目は、今までどおりもう片方の目の視線を使います。0 なら送るものは今までと同じです。「左右の目を別々に動かす」がオフのときと v1(もともと目ごとの視線をそのまま送ります)のときは薄く表示されます @@ -10,6 +10,7 @@ SteamOS が開いた片目を閉じていると読み続けても、視線が止 - 目のカメラを使ったまぶた(SteamOS 0.4.x): SteamOS の目のトラッキングは開いた目をカメラよりかなり低く読むことが多く、両者がそろわないまま、まばたきのたびにカメラの遅れの分だけまぶたの「疑い」が積み重なって、最後はカメラがずっとまぶたを動かすようになり、カメラが見逃したまばたきは開いたまま送られていました。今は、SteamOS が目を 1 秒開いていると読んだら疑いをやり直します。流し直しでは、右目がカメラのまぶたになっていた時間が 0.67 時間のうち 130 秒から 111 秒になり、まぶたのサンプルの 0.3〜0.6 % が変わりました - eyecam、ユーザー校正: 細めは、そのかぶりの「開いた〜閉じた」の幅に対する割合で保存されていたのに、使うときは普段の幅に掛けていました。この幅はかぶるたびに 0.6〜1.5 倍違うので、校正した細めの深さが 0.65〜1.5 倍ずれていました。今はユーザー校正で細めの深さそのものも保存し(各目のユーザーの値の `sq_depth`)、使うときもそれを、校正と同じ上限・下限の中で使います。案内に沿った「普通」の段で開いた目が細めと読まれたのは 2.4 % から 0.8 % になり、いちばん細めたときは少し低めに読まれます(0.9 以上が 53 % から 35 %)。新しくユーザー校正をしたあとから効き、前の校正は今まで通り動きます。細めが浅すぎると感じたら、ユーザー校正をやり直してください。前の版に戻してそこで校正を保存すると `sq_depth` が消えるので、更新したあとにユーザー校正をやり直してください - eyecam、度付きインサート: 目のカメラの前にインサートがあると、閉じた目が少し開いていると読まれたり、虹彩が大きすぎると測られて閉じが見えにくくなったりしていました。閉じた目で見つけた下まぶたの線が、開いていたときより虹彩の半径の 0.9 倍以上下にあるときや、探す範囲の下端に張り付いているときは使わず、最後に瞳が見えていたときの線を使います(下まぶたはまばたきでほとんど動かないため)。瞳が欠けずに見えているのに上まぶたの線が瞳の中心より下にあるときも、その線は使いません。そういうかぶりの校正のサンプルで測り直すと、左目の「開いた〜閉じた」の幅は 0.30 から 1.35 になりました(右目は 1.31)。かぶりの虹彩の半径が開いた瞳の 4 倍を超えたら、虹彩の縁をいちばん強い明るさの変わり目ではなく、虹彩から出て最初の立ち上がりで測り、半径と開いた目の iris box の明るさを覚え直します。半径を 61 / 54 px から 79 / 75 px に引っぱる帯を足した録画 26 本で流し直すと、半径は 72 / 67 px、閉じの取りこぼしは 1 分あたり 1.27 回から 1.08 回、0.2 秒未満のまばたきは 80 % から 83 % 取れるようになり、そのかわり開いた目が半目と読まれたのは 5.3 % から 6.4 % になりました。元の録画では半径の切り替えは起きず、案内に沿った閉じが取れたのは 97.3 % から 98.2 % で、ほかはほぼ同じでした(開いた目が半目と読まれたのは 5.3 % から 5.4 %)。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください +- eyecam、度付きインサートと見開き: 白目が明るく照らされていてインサートの反射もあると、上まぶたの線をその明るいところの上の縁(瞳の中心より下か、すぐ上)で取ってしまうことが多く、かぶりの校正では見開きの高さを長い上まつ毛の先で取っていました。そのため見開きの幅が普段の 3〜4 倍になって見開きに届かず、開いた目は細めに読まれていました。瞳が欠けずに見えているのに線が瞳の上半分より下にあるときは、瞳の上でもう一度探し、見つかった線が元の線と同じくらい以上はっきりしていればそちらを使います。見開きの高さを上の範囲から取るときに、その範囲で普段の段にも線があれば、その線が上がった分を見開きの幅にします。虹彩の半径の 1.1 倍を超える見開きの幅は見開きが取れなかったものとして扱い(いつもの幅を使います)、すでに保存されている 0.9 倍を超える幅は履歴の中央値に入れません。そういう人の録画で流し直すと、見開きの幅は 1.30 / 1.15 から 0.52 / 0.51 になりました。同じ録画で作ったユーザー校正を当てた(甘めの)確認では、右目は見開きの間の約 90 % で見開きと読まれ(前は 0 %)、左目は 19〜76 %(前は 0〜35 %)、普段の段の細めは 0.16〜0.63 から 0〜0.07 になりました。左目の見開いた線は虹彩の上端に乗ることが多く、見開きはまだ少し低めに出ることがあります。開発者の録画では校正は同じ値で、19 本のうち 1 本でサンプルの 0.16 % が変わりました。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください。 - ログ: 「Eye data has been low」の行で、取りこぼしがないときに「-0 published samples were missed」と出ていたのを、0 と出すようにしました。 ## 0.8.1 (2026-10-21) diff --git a/CHANGELOG.md b/CHANGELOG.md index a479556..88748aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -## 0.8.2-beta.1 (2026-10-22) +## 0.8.2-beta.2 (2026-10-24) -The gaze no longer stays frozen when SteamOS keeps reading one open eye as closed: after a second, the other eye moves the gaze (#32). With "Move eyes separately" on, the new "Own gaze per eye" (Gaze › Reach) lets each eye's own sideways gaze through, so looking inward with both eyes can show. The eye cameras no longer lose a pupil that suddenly looks larger, the camera eyelids no longer take over for good after many blinks on SteamOS 0.4.x, and a new user calibration now sets the squint at the same depth whatever the wear. +The gaze no longer stays frozen when SteamOS keeps reading one open eye as closed: after a second, the other eye moves the gaze (#32). With "Move eyes separately" on, the new "Own gaze per eye" (Gaze › Reach) lets each eye's own sideways gaze through, so looking inward with both eyes can show. The eye cameras no longer lose a pupil that suddenly looks larger, the camera eyelids no longer take over for good after many blinks on SteamOS 0.4.x, and a new user calibration now sets the squint at the same depth whatever the wear. With prescription inserts, the eye cameras should find the upper eyelid more reliably, so widening can show and an open eye is less often read as a squint. -日本語: SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。 +日本語: SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。度付きインサートを使っているときも、目のカメラが上まぶたを見つけやすくなるはずです。見開きが出るようになり、開いた目が細めに読まれることも減ります。 - Gaze: the gaze is held while either eye's openness is below `gaze_hold_below` (0.5), for blinks. SteamOS can read one open eye at about 0.06 for minutes to hours (with prescription inserts, for example), and the gaze stood still all that time while the eyelids and pupils kept moving. Now one eye alone below the line holds the gaze for up to 1 s (a wink); longer, and that eye's reading is taken as wrong: it no longer holds the gaze, and the other eye moves both. Blinks are held as before, and an eye read closed for good is not held again for a second after each blink. A single sample read open doesn't start the second over. - New setting `own_eye_gaze` (0 by default, 0 to 1; `--own-eye-gaze`), "Own gaze per eye" on Gaze › Reach under "Move eyes separately" (0-100 %, 5 % steps). With "Move eyes separately" on (v2), it blends each eye's sideways gaze from the shared one turned in by 2° (0 %, as before) to each eye's own sideways gaze as the Frame tracks it (100 %). The Frame's per-eye sideways gaze wobbles, so the avatar's eyes may twitch at higher values. Up/down, the eyelids and everything else stay as they are; an eye read closed for long (above) or with an unreliable gaze still takes the other's. At 0 everything sent is as before. It is greyed out while "Move eyes separately" is off, and in v1 (which already sends each eye's own gaze). @@ -12,6 +12,7 @@ The gaze no longer stays frozen when SteamOS keeps reading one open eye as close - Eyelids with the eye cameras on SteamOS 0.4.x: its eye tracker often reads an open eye well below the camera, so the two never agreed and each blink's camera lag added to the eyelid's "doubt" until the camera drove the eyelid for good, and blinks the camera missed went out open. The doubt now starts over once the eye tracker has read the eye open for 1 s. In replays the right eye's time on the camera eyelid went from 130 s to 111 s in 0.67 h; 0.3-0.6 % of eyelid samples changed. - eyecam, user calibration: the squint was saved as a fraction of that wear's open-to-closed gap but used against the usual gap, which differs by 0.6 to 1.5 times from wear to wear, so the calibrated squint came out 0.65 to 1.5 times as deep. The user calibration now also saves the squint's depth (`sq_depth` in each eye's user values), and live uses it, kept within the same limits as the calibration's. Open eyes read as a squint went from 2.4 % to 0.8 % in the guided normal step; a full squint reads a little lower (0.9 or more 35 % of the time instead of 53 %). This takes effect after a new user calibration; older calibrations work as before. If your squint now reads too shallow, run the user calibration again. Going back to an older version and saving a calibration there drops `sq_depth`, so run the user calibration again after updating. - eyecam, prescription inserts: with an insert in front of the eye cameras, a closed eye could read partly open, and an iris fit far too large made closes harder to see. A lower lid line found on a closed eye 0.9 iris radii or more below where it was with the eye open, or against the bottom of its search band, is no longer taken: the last one seen with the pupil is used instead (the lower lid hardly moves in a blink), and an upper lid line below the centre of a pupil seen whole is dropped. Re-fitting one such wear calibration's samples, the left eye's open-to-closed gap goes from 0.30 to 1.35 (its right eye: 1.31). When a wear's iris radius comes out over 4 times the open pupil's, the iris edge is taken at the first rise out of the iris instead of the strongest edge, and the radius and the open eye's iris-box level are learnt again. Replayed on 26 recordings with a band added that pulls the radius from 61 / 54 px to 79 / 75 px: 72 / 67 px, closes missed 1.08 instead of 1.27 a minute, blinks under 0.2 s caught 83 % instead of 80 %, but open eyes read half-shut 6.4 % of the time instead of 5.3 %. On the original recordings the radius switch never happens; guided closes were caught 98.2 % of the time instead of 97.3 %, and everything else stayed about the same (open eyes read half-shut 5.4 % of the time instead of 5.3 %). If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. +- eyecam, prescription inserts and widening: with a brightly lit white of the eye and glints from an insert, the upper lid line was often taken at the bright area's upper edge, under or just above the pupil's centre, and the wear calibration's widen level at the tips of long upper lashes, so the widen step came out 3 to 4 times the usual size and widening could never be reached, while open eyes read as a squint. Now, when the pupil is seen whole and the line lies below the upper half of the pupil, the lid is searched for again above the pupil, and that line is taken if it is at least as clear. When the widen level comes from the higher search area and that area also has a line in the normal step, the widen step is how far that line rose. A widen step above 1.1 iris radii is taken as a widen not caught (the usual step is used), and steps above 0.9 already saved are left out of the history's median. Replaying one such wearer's recording, the widen step went from 1.30 / 1.15 to 0.52 / 0.51, and with a user calibration made from that same recording (so a favourable test) the right eye read as widened in about 90 % of its widen frames instead of none (the left eye 19 to 76 % instead of 0 to 35 %), and the squint in the normal step went from 0.16-0.63 to 0-0.07. The left eye's widened line often sits at the top of the iris, so its widening may still read a little low. On the developer's recordings, the calibrations came out the same, and 0.16 % of the samples in one of 19 recordings changed. If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. - Logs: the "Eye data has been low" line said "-0 published samples were missed" when none were; it now says 0. ## 0.8.1 (2026-10-21) From 5b08e6f06f4b0395c6765ace627953c5931d0b64 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sat, 24 Oct 2026 17:31:25 +0900 Subject: [PATCH 29/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E7=9B=AE?= =?UTF-8?q?=E3=81=AE=E3=82=AB=E3=83=A1=E3=83=A9=E3=81=AE=E6=A0=A1=E6=AD=A3?= =?UTF-8?q?=E4=B8=AD=E3=81=AF=E3=80=81=E8=A6=96=E7=B7=9A=E3=81=AE=E5=90=88?= =?UTF-8?q?=E3=82=8F=E3=81=9B=E7=9B=B4=E3=81=97=E3=81=A8=E7=9B=AE=E5=90=88?= =?UTF-8?q?=E3=82=8F=E3=81=9B=E3=82=92=E5=A7=8B=E3=82=81=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ユーザー校正の途中にほかのアプリから --action recenter が届くと、点が出て、 校正のために上を見ているときの視線の正面を保存していた。 - eyecam::calibrationUnderWay: eyecam-rec が calibrating(目の映像を待つ間も)か、 パネルが送った校正の返事待ち - --action recenter / fit / relearn は校正中なら 11 と "busy (the eye cameras are calibrating)" (frame-aux-shortcuts 0.4.0 は 11 + calibrating を「目のカメラの校正中です」と出す) - 目を合わせるタブの[目を合わせる][真ん中だけ合わせ直す]を校正中はグレーにし、理由を見出しに出す - 被ったときの自動の合わせ直しは、校正が終わるまで待つ - テスト、README の終了コードの表、更新履歴 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.ja.md | 1 + CHANGELOG.md | 1 + README.ja.md | 2 +- README.md | 2 +- panel/README.md | 10 +++--- panel/src/action_socket.cpp | 6 ++++ panel/src/action_socket.h | 10 ++++-- panel/src/auto_recenter.cpp | 11 +++++++ panel/src/auto_recenter.h | 10 ++++-- panel/src/eyecam.cpp | 5 +++ panel/src/eyecam.h | 10 ++++++ panel/src/i18n.cpp | 2 ++ panel/src/i18n.h | 1 + panel/src/main.cpp | 4 +++ panel/src/panel.cpp | 11 ++++++- panel/tests/action_socket_test.cpp | 45 ++++++++++++++++++++++++- panel/tests/auto_recenter_test.cpp | 53 ++++++++++++++++++++++++++++++ panel/tests/eyecam_test.cpp | 37 +++++++++++++++++++++ panel/tests/panel_test.cpp | 18 ++++++++++ 19 files changed, 224 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 3ef625e..66a83e3 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -11,6 +11,7 @@ SteamOS が開いた片目を閉じていると読み続けても、視線が止 - eyecam、ユーザー校正: 細めは、そのかぶりの「開いた〜閉じた」の幅に対する割合で保存されていたのに、使うときは普段の幅に掛けていました。この幅はかぶるたびに 0.6〜1.5 倍違うので、校正した細めの深さが 0.65〜1.5 倍ずれていました。今はユーザー校正で細めの深さそのものも保存し(各目のユーザーの値の `sq_depth`)、使うときもそれを、校正と同じ上限・下限の中で使います。案内に沿った「普通」の段で開いた目が細めと読まれたのは 2.4 % から 0.8 % になり、いちばん細めたときは少し低めに読まれます(0.9 以上が 53 % から 35 %)。新しくユーザー校正をしたあとから効き、前の校正は今まで通り動きます。細めが浅すぎると感じたら、ユーザー校正をやり直してください。前の版に戻してそこで校正を保存すると `sq_depth` が消えるので、更新したあとにユーザー校正をやり直してください - eyecam、度付きインサート: 目のカメラの前にインサートがあると、閉じた目が少し開いていると読まれたり、虹彩が大きすぎると測られて閉じが見えにくくなったりしていました。閉じた目で見つけた下まぶたの線が、開いていたときより虹彩の半径の 0.9 倍以上下にあるときや、探す範囲の下端に張り付いているときは使わず、最後に瞳が見えていたときの線を使います(下まぶたはまばたきでほとんど動かないため)。瞳が欠けずに見えているのに上まぶたの線が瞳の中心より下にあるときも、その線は使いません。そういうかぶりの校正のサンプルで測り直すと、左目の「開いた〜閉じた」の幅は 0.30 から 1.35 になりました(右目は 1.31)。かぶりの虹彩の半径が開いた瞳の 4 倍を超えたら、虹彩の縁をいちばん強い明るさの変わり目ではなく、虹彩から出て最初の立ち上がりで測り、半径と開いた目の iris box の明るさを覚え直します。半径を 61 / 54 px から 79 / 75 px に引っぱる帯を足した録画 26 本で流し直すと、半径は 72 / 67 px、閉じの取りこぼしは 1 分あたり 1.27 回から 1.08 回、0.2 秒未満のまばたきは 80 % から 83 % 取れるようになり、そのかわり開いた目が半目と読まれたのは 5.3 % から 6.4 % になりました。元の録画では半径の切り替えは起きず、案内に沿った閉じが取れたのは 97.3 % から 98.2 % で、ほかはほぼ同じでした(開いた目が半目と読まれたのは 5.3 % から 5.4 %)。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください - eyecam、度付きインサートと見開き: 白目が明るく照らされていてインサートの反射もあると、上まぶたの線をその明るいところの上の縁(瞳の中心より下か、すぐ上)で取ってしまうことが多く、かぶりの校正では見開きの高さを長い上まつ毛の先で取っていました。そのため見開きの幅が普段の 3〜4 倍になって見開きに届かず、開いた目は細めに読まれていました。瞳が欠けずに見えているのに線が瞳の上半分より下にあるときは、瞳の上でもう一度探し、見つかった線が元の線と同じくらい以上はっきりしていればそちらを使います。見開きの高さを上の範囲から取るときに、その範囲で普段の段にも線があれば、その線が上がった分を見開きの幅にします。虹彩の半径の 1.1 倍を超える見開きの幅は見開きが取れなかったものとして扱い(いつもの幅を使います)、すでに保存されている 0.9 倍を超える幅は履歴の中央値に入れません。そういう人の録画で流し直すと、見開きの幅は 1.30 / 1.15 から 0.52 / 0.51 になりました。同じ録画で作ったユーザー校正を当てた(甘めの)確認では、右目は見開きの間の約 90 % で見開きと読まれ(前は 0 %)、左目は 19〜76 %(前は 0〜35 %)、普段の段の細めは 0.16〜0.63 から 0〜0.07 になりました。左目の見開いた線は虹彩の上端に乗ることが多く、見開きはまだ少し低めに出ることがあります。開発者の録画では校正は同じ値で、19 本のうち 1 本でサンプルの 0.16 % が変わりました。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください。 +- パネル: 目のカメラの校正中は、視線の合わせ直しと目合わせが始まらないようにしました。ユーザー校正の途中にほかのアプリから合わせ直しを頼まれると(frame-aux-shortcuts の 2 回タップなど)、点が出て、校正のために上を見ているときの視線の正面を保存していました。`--action recenter`・`fit`・`relearn` は 11 で終わり(1 行に「calibrating」と出ます)、その間は[目を合わせる]と[真ん中だけ合わせ直す]がグレーになり、被ったときの自動の合わせ直しは校正が終わるまで待ちます - ログ: 「Eye data has been low」の行で、取りこぼしがないときに「-0 published samples were missed」と出ていたのを、0 と出すようにしました。 ## 0.8.1 (2026-10-21) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88748aa..4a335fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The gaze no longer stays frozen when SteamOS keeps reading one open eye as close - eyecam, user calibration: the squint was saved as a fraction of that wear's open-to-closed gap but used against the usual gap, which differs by 0.6 to 1.5 times from wear to wear, so the calibrated squint came out 0.65 to 1.5 times as deep. The user calibration now also saves the squint's depth (`sq_depth` in each eye's user values), and live uses it, kept within the same limits as the calibration's. Open eyes read as a squint went from 2.4 % to 0.8 % in the guided normal step; a full squint reads a little lower (0.9 or more 35 % of the time instead of 53 %). This takes effect after a new user calibration; older calibrations work as before. If your squint now reads too shallow, run the user calibration again. Going back to an older version and saving a calibration there drops `sq_depth`, so run the user calibration again after updating. - eyecam, prescription inserts: with an insert in front of the eye cameras, a closed eye could read partly open, and an iris fit far too large made closes harder to see. A lower lid line found on a closed eye 0.9 iris radii or more below where it was with the eye open, or against the bottom of its search band, is no longer taken: the last one seen with the pupil is used instead (the lower lid hardly moves in a blink), and an upper lid line below the centre of a pupil seen whole is dropped. Re-fitting one such wear calibration's samples, the left eye's open-to-closed gap goes from 0.30 to 1.35 (its right eye: 1.31). When a wear's iris radius comes out over 4 times the open pupil's, the iris edge is taken at the first rise out of the iris instead of the strongest edge, and the radius and the open eye's iris-box level are learnt again. Replayed on 26 recordings with a band added that pulls the radius from 61 / 54 px to 79 / 75 px: 72 / 67 px, closes missed 1.08 instead of 1.27 a minute, blinks under 0.2 s caught 83 % instead of 80 %, but open eyes read half-shut 6.4 % of the time instead of 5.3 %. On the original recordings the radius switch never happens; guided closes were caught 98.2 % of the time instead of 97.3 %, and everything else stayed about the same (open eyes read half-shut 5.4 % of the time instead of 5.3 %). If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. - eyecam, prescription inserts and widening: with a brightly lit white of the eye and glints from an insert, the upper lid line was often taken at the bright area's upper edge, under or just above the pupil's centre, and the wear calibration's widen level at the tips of long upper lashes, so the widen step came out 3 to 4 times the usual size and widening could never be reached, while open eyes read as a squint. Now, when the pupil is seen whole and the line lies below the upper half of the pupil, the lid is searched for again above the pupil, and that line is taken if it is at least as clear. When the widen level comes from the higher search area and that area also has a line in the normal step, the widen step is how far that line rose. A widen step above 1.1 iris radii is taken as a widen not caught (the usual step is used), and steps above 0.9 already saved are left out of the history's median. Replaying one such wearer's recording, the widen step went from 1.30 / 1.15 to 0.52 / 0.51, and with a user calibration made from that same recording (so a favourable test) the right eye read as widened in about 90 % of its widen frames instead of none (the left eye 19 to 76 % instead of 0 to 35 %), and the squint in the normal step went from 0.16-0.63 to 0-0.07. The left eye's widened line often sits at the top of the iris, so its widening may still read a little low. On the developer's recordings, the calibrations came out the same, and 0.16 % of the samples in one of 19 recordings changed. If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. +- Panel: while the eye cameras calibrate, the gaze re-center and the eye fit no longer start. A re-center asked for by another app (a double tap in frame-aux-shortcuts, say) during a user calibration showed its dot and saved the gaze center measured while you were looking up for the calibration. `--action recenter`, `fit` and `relearn` now exit with 11 and "calibrating" in their line, "Fit my eyes" and "Re-center only" are grey meanwhile, and the automatic re-center after putting the headset on waits for the calibration to end. - Logs: the "Eye data has been low" line said "-0 published samples were missed" when none were; it now says 0. ## 0.8.1 (2026-10-21) diff --git a/README.ja.md b/README.ja.md index 799bd98..73cdfcc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -169,7 +169,7 @@ Frame のほかのアプリ(コントローラーのボタンなど)から | 8 | 目合わせの値が本体のコマンドで固定中(ボタンもグレー) | | 9 | 本体は動いているが目のデータが来ていない(たいていはヘッドセットをかぶっていない) | | 10 | 使用中: 合わせ直しが動いている(かぶったときの自動のものも。先に `stop`) | -| 11 | `relearn`: 目のカメラを使っていない・準備ができていない(オフ、準備前、eyecam が動いていない・ほかのことをしている、目の映像が来ていない)か、eyecam が断った | +| 11 | `relearn`: 目のカメラを使っていない・準備ができていない(オフ、準備前、eyecam が動いていない・ほかのことをしている、目の映像が来ていない)か、eyecam が断った。目のカメラの校正中の `recenter`・`fit`・`relearn` も(1 行に `calibrating` と出る。その間は目合わせのボタンもグレー) | パネルは 4 分の 1 秒ほどで答えます(ダッシュボードを閉じているときのループの 1 回分)。`relearn` は eyecam の返事も待つので、長くてもあと 1 秒。動いているパネルとは、自分だけが使えるソケット(`$XDG_RUNTIME_DIR/frameeyeosc-panel.sock`。詳しくは [panel/README.md](panel/README.md#ほかのアプリから動かす--action))でやりとりします。待っている間、パネルはそのソケットを見るだけで、要求が来たときに状態ファイルと `config.json` を 1 回読みます。入っているパネルの版は `--version` でわかります。 diff --git a/README.md b/README.md index f7952ae..eda2cb3 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Each works like pressing the button (and only when the button could be pressed), | 8 | The fit's values are locked by frameeyeosc's command line (the buttons are grey too) | | 9 | frameeyeosc runs but no eye data comes in (most often the headset isn't on) | | 10 | Busy: a re-center is running (also the automatic one after putting the headset on; `stop` it first) | -| 11 | `relearn`: the eye cameras aren't in use or ready (off, not set up, eyecam not running or busy, no eye video), or eyecam said no | +| 11 | `relearn`: the eye cameras aren't in use or ready (off, not set up, eyecam not running or busy, no eye video), or eyecam said no. Also `recenter`, `fit` and `relearn` while the eye cameras are calibrating (the line then says `calibrating`; the fit buttons are grey meanwhile) | The panel answers within about a quarter of a second (one pass of its loop with the dashboard closed); `relearn` waits for eyecam's answer too, at most a second more. It talks to the running panel over a socket only you can use (`$XDG_RUNTIME_DIR/frameeyeosc-panel.sock`, details in [panel/README.md](panel/README.md#ほかのアプリから動かす--action)). The panel only looks at that socket while it waits; a request makes it read the status file and `config.json` once. `--version` tells which panel is installed. diff --git a/panel/README.md b/panel/README.md index dfb1590..cb1deea 100644 --- a/panel/README.md +++ b/panel/README.md @@ -28,7 +28,7 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ | 基本 | 送信(送る / 止める)、送り先(3 枚のカード: VRChat に直接 / VRCFT(LiveLink)「おすすめ」 / VRCFT(FrameEyeOSC)「全部入り」。タグはカードの上の縁に。それぞれ 2 行で 同期・PC に何が要るか(`outputMarks[4][2]`。見開きの行は無い)。FrameEyeOSC の同期は日本語版が二重丸(`ICON_DOUBLE_RING`)、英語版が「✓ Synced, pupils too」。選んだカードは太い枠と左上の ✓、コマンドで固定中は灰色。カードの下に小さな[VRCFT(ETVR)](高さ 40、選んでいれば ✓ とオレンジの枠)と「削除予定」、ETVR の 2 行を「 ・ 」でつないで 1 行で。「VRChat に直接」を選んでいるときは、その小さなボタンの上に灰色の 1 行「目が動かないアバターは、VRCFT(LiveLink か FrameEyeOSC)にすると動きます」(`vrchatDirectHint`)。ETVR を選んでいるときだけ、カードとボタンのあいだにオレンジの案内「いまは VRCFT(ETVR)に送っています。次の版で ETVR はなくなります」「上の 3 つから、ほかの送り先を選び直してください」〔`outputDeprecated`〕。勝手にほかの送り先へは移さない)、目のカメラ(準備前は「使っていない」と[はじめる →](目のカメラタブへ。タブがあるときだけ)。準備後は「使う / 使わない」(`camera_lids`。0.7.6 までは目のカメラタブの「カメラで瞼を取る」)と短い状態: 左・右 OK / 左だけ / 右だけ / オフ / 目を覚えてる途中 / 待機中(`cameraBrief`))、言語(日本語 / English。右に[すべて既定に戻す])、SteamVR と一緒に起動(オン / オフ。右に[アプリを終了])。いちばん下の「押すとすぐ本体に反映され…」は入るときだけ(ETVR の案内が出ているときは省く) | | 送り方 | 送り先の PC(自動 / 今の相手で固定 / IP を入力。手で決めた host は 3 つ目に「手動 192.168.1.20」と出る)、ポート(− / +、既定に戻す。ヒントに送り先の種類の既定)。VRChat に直接のときだけ パラメーター名の頭(/FT / なし、送るアドレスの例)、EyeTrackingActive の型(Bool / Float / 送らない、`eye_tracking_active`)、Steam Link の名前も送る(オン / オフ、`steamlink_params`。Steam Link の OSC 向けのアバター用。送るアドレスの例に「頭はつけない」)、VRChat 標準の目も動かす(オン / オフ、`native_eyes`。VRCFT 用の値がないアバター用)。LiveLink / FrameEyeOSC / ETVR のときは「PC の VRCFT で準備すること」の手順(LiveLink と ETVR は 3 つ。FrameEyeOSC は 4 つ: PC のブラウザーで zip を落とす・「Module Registry」の[Install from .zip]・ほかの目のモジュールはオフ・UDP 11112。1 つ目の下にアクセント色の入手先 github.com/sasaken1102r/VRCFT-FrameEyeOSC/releases があり、押すと Frame の Chromium でそのページを開く〔`setup_tools::kModulePageUrl`〕。開けなかったら、そのすぐ下に赤の 1 行「ブラウザーを開けませんでした: 理由」〔目のカメラの準備の画面には出さない。次に開けたときと送り先が変わったときに消える。`--fake-module-link-error`〕)と注記(入りきらないとき〔LiveLink で目のカメラを使っているとき〕は注記を省く。FrameEyeOSC で目のカメラを使っているときは「瞳孔の大きさも、VRCFT がアバターに合った形で送ります。」を足す)。FrameEyeOSC には瞳孔の行が無い(VRCFT が送る)。目のカメラを使っていないときだけ点線の 1 行。目のカメラを使っているとき(`eyecam::inUse`: 準備が済んでいて `camera_lids` がオン)は、LiveLink なら「瞳孔は VRChat に直接送る」(`pupils_to_vrchat`)、VRChat に直接と LiveLink なら「瞳孔の受け取り方」(小数 / 1 / 2 / 3 / 4、`pupil_bits`。PupilDilation1・2・4・8 の bool も送る個数。LiveLink で `pupils_to_vrchat` がオフのときは灰色で押せない)。どちらも緑の「カメラ」バッジ付き。使っていないときはその場所に点線の 1 行「目のカメラを使うと、瞳孔の大きさも送れるよ」(押すと目のカメラタブ。`pupilsLineShown`) | | 視線 | いちばん上の切り替えで 3 つの小タブ(詳細・まぶたタブと同じ形。初めては「動き」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `gaze_page` で覚えている)。「動き」: スムージング(オン / オフ)、なめらかさ(弱 / 中 / 強)、細かく変える(止まっている時・速い動き・変化の感度の 3 つを − / +)、見つめている時の遊び(角度も表示)。「動く範囲」: 視線の倍率(左・右、上・下の − 100% +。5〜200 %、5 % 刻み、`gaze_scale_left/right/up/down`。ヒント「目合わせのあとに掛けます」。左右・上下のあいだに鍵: 閉じている(アクセント色)あいだは片方を変えるともう片方も同じ値に、開く(灰色)と別々、閉じ直すと右 := 左・下 := 上(`gazeScaleStep` / `gazeScaleRelink`)。鍵は `panel.json` の `gaze_scale_link_x` / `_y` に覚え、起動時に値が違う組は開いて見せる(`gazeScaleLinkShown`)。どちらかがコマンドで固定中なら鍵は押せない)、左右の目を別々に動かす(Frame が片目だけで追っているあいだは、ヒントが「片目だけ追跡中は両目が同じ向き」になる)、その下に目ごとの視線(`own_eye_gaze` の − / +、0〜100 %、5 % 刻み。右に「0 % で安定、100 % で目ごと(ぶれる)」。「別々に動かす」がオフのときは薄くして押せなくし、v1 のときは薄くしたうえで右を「v2 だけで効くよ。今は v1 なので使ってない」と[詳細 › バージョンへ]に)。「乱れ対策」: まばたき中は視線を止める(オン / オフとしきい値)、不確かな視線を使わない(オン / オフと上限)、一瞬の途切れを消す(オン / オフ)。`--gaze-page motion|reach|glitch` で小タブを選んで描き、`--fake-gaze-scale L,R,U,D` で倍率を入れる | -| 目を合わせる | [目を合わせる](ヒント「正面・上の幅・まぶた・約 20 秒」。合わせたあとは[もう一度合わせる])と小さい[真ん中だけ合わせ直す]、「被ったとき」の行(何もしない / 真ん中だけ、`auto_recenter`。既定は真ん中だけ。0.7.6 までの `"tilt"` は `"center"` として読む)、今どうなっているか(始め方・結果[元に戻す]・止めた / 失敗の理由。被ったときの合わせ直しをダッシュボードが止めて、もう一度やるときは「ダッシュボードを閉じたら、もう一度合わせ直します」も)。目を合わせている間はタブ全体が「点を見てください」と何をするか(動かない文字)と大きな[やめる]だけになり、左の列とタブの並びは暗くなる(押せるのはタブと[やめる]だけ。ほかのタブを押すと止まる)、[♪ 音を鳴らす: オン / オフ]、「細かく直す」(開閉。[視線 / まぶた]で切り替え。視線: 正面の位置・動く幅(左右・上・下)、真下で左右を止める角度。まぶた: 目ごとの読んだ値 閉じ / 上 / 正面 / 下 の − / +。閉じているあいだは、その下に「合わせ方」の箱: 被るたびは真ん中の点を 1 つ見るだけ(自動)、[目を合わせる]は最初と気になったときだけ、傾きは使わなくなったこと) | +| 目を合わせる | [目を合わせる](ヒント「正面・上の幅・まぶた・約 20 秒」。合わせたあとは[もう一度合わせる])と小さい[真ん中だけ合わせ直す]、「被ったとき」の行(何もしない / 真ん中だけ、`auto_recenter`。既定は真ん中だけ。0.7.6 までの `"tilt"` は `"center"` として読む)、今どうなっているか(始め方・結果[元に戻す]・止めた / 失敗の理由。被ったときの合わせ直しをダッシュボードが止めて、もう一度やるときは「ダッシュボードを閉じたら、もう一度合わせ直します」も)。目のカメラの校正中(`eyecam::calibrationUnderWay`: eyecam-rec が `calibrating`、目の映像を待っている間も。パネルが送って返事を待っている間も)は[目を合わせる]と[真ん中だけ合わせ直す]がグレーで、箱の見出しが「目のカメラの校正が終わってから使えます」。目を合わせている間はタブ全体が「点を見てください」と何をするか(動かない文字)と大きな[やめる]だけになり、左の列とタブの並びは暗くなる(押せるのはタブと[やめる]だけ。ほかのタブを押すと止まる)、[♪ 音を鳴らす: オン / オフ]、「細かく直す」(開閉。[視線 / まぶた]で切り替え。視線: 正面の位置・動く幅(左右・上・下)、真下で左右を止める角度。まぶた: 目ごとの読んだ値 閉じ / 上 / 正面 / 下 の − / +。閉じているあいだは、その下に「合わせ方」の箱: 被るたびは真ん中の点を 1 つ見るだけ(自動)、[目を合わせる]は最初と気になったときだけ、傾きは使わなくなったこと) | | まぶた | 見た目の調整を全部ここに(案E)。いちばん上の切り替えで 4 つの小タブ(詳細タブと同じ形。初めては「見た目」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `lids_page` で覚えている)。「見た目」: 「いまのまぶた」の帯(目のカメラ(左・右)/ 目のカメラ(左)+ Valve(右)/ 目のカメラ(待機中)/ Valve の値。準備が済んでいれば右に「オン・オフは「基本」タブで」)、「見開き」の行(する / しない で `widen` を `SetBool`。カメラを使っていてもいなくてもいつも出す。ヒント「閉じ・細めはそのまま」。「しない」のあいだは下の「見開きの出やすさ」を灰色にして押せなくし、その下の 1 行を「「見開き」が「しない」なので、いまは使わないよ」に。目のカメラタブのいちばん下の文も「カメラで両目を見て、まぶたを送ってるよ(見開きはしない)」に、診断の「まぶた」の行には「· 見開きしない」を足す。`--fake-no-widen` で描ける)、目のカメラを使っているときだけ「見開きの出やすさ」のスライダー 1 本(緑の「カメラ」バッジ。鈍い ↔ 敏感。両目がカメラなら eyecam-rec の `widen_sensitivity` を `set widen_sensitivity` で送る。片目だけカメラなら同じく感度を送り、Valve の目には近い `lid_widen` の段階も一緒に書く。Valve の値なら `lid_widen` の 4 段階(しない / 控えめ / ふつう / 出やすい)で、押した所に近い止まり位置へ。目を合わせていない目があると下に「目を合わせていない目は、「細かく直す」の目盛りで決まるよ」。いまカメラの目が無く、ふつうに開いた目が 1.0 と読まれる SteamOS(`openness_saturated`)では、スライダーの代わりに 1 行: ヒント「この SteamOS では効かないよ」、「Valve の値だけだと見開きが出ないよ」と[目のカメラへ →](目のカメラタブがあるときだけ))、目のカメラを使っていて eyecam-rec の status.json に `lid_veto.on` があるときだけ「半目の読み直し(AI)」(する / しない。緑の「カメラ」バッジ。eyecam-rec に `set lid_veto on|off` を送り、選ばれているほうは status.json の `lid_veto.on` のまま〔panel.json には持たない〕。下に 3 行までの説明。`--fake-eyecam …:veto=on|off` で描ける)、まぶたのなめらかさ(弱 / 中 / 強。中 = 本体の既定値、どれにも合わなければカスタム)、左右をそろえる強さ(`lid_sync` のスライダー、0.05 刻み)。目のカメラを使っていないときは、いちばん下に点線の 1 行「目のカメラを使うと「見開きの出やすさ」が増えるよ」(押すと目のカメラタブ)。なので、カメラが無いと `lid_widen` はパネルから変えられない。「まばたき・細め」: まばたきを届ける(保持 ms の − / +)、まばたきを両目でそろえる(`blink_sync_below` の − / +、0 でオフ)、細めたときのまぶたの下限(`camera_lid_floor` の − / +、0.05 刻み、0 でオフ。目のカメラを使っているときだけ出す。緑の「カメラ」バッジ。カメラの目がまだないときは「目のカメラが目を見てるときに効くよ」と添える。使っていないときは点線の 1 行「目のカメラを使うと「細めたときのまぶたの下限」も使えるよ」)。それぞれ右に説明。「細かく直す」: いちばん上に目盛りの決まり方の 1 行、自動キャリブレーション(目を合わせていない目で、カメラでないとき)、左右の倍率、生の値の棒と 4 つの目盛り(③④は目を合わせた目とカメラのときは灰色)、ほぼ開いてたら全開にする(⑤。生の値の棒にも ⑤ の青い破線を出す。目を合わせた目は倍率をかけた位置、カメラが読んでいる目は薄く、0.75 のときは出さない。行の数字と − / + の縁も同じ青。`lid_open_snap` の − / +、送るまぶたの値で 0〜0.75 の 0.01 刻み、0.75 でオフ。いつも出す。右に説明。開いた値に対する割合だったころの 0.75 より大きい値は、起動時に一度だけ置き換える(`migrateLidOpenSnap`))、なめらかさの 2 つの値(止まっている時・速い動き。ページに収まるよう、それぞれの左に名前を置いた 1 行)。目のカメラタブの[まぶたへ →]は「見た目」を開く。「眉」: 「見開きで眉も上げる」(する / しない で `brow_from_widen` を `SetBool`。ヒント「カメラは眉を見ていないよ」。カメラを使っていてもいなくても、「見開き」が「しない」でもいつも出す。下に 3 行までの説明〔`\n` の所で改行〕)と、「する」のあいだだけ「眉の上がり方」(`brow_strength` のスライダー、弱い ↔ 強い、0.05 刻み。ヒント「アバターに合わせて」)。`--fake-brows` で「する」を描ける。`--lids-page look|blinks|fine|brows` で小タブを選んで描く(`--lid-marks` は fine と同じ) | | 目のカメラ | eyecam-rec が動いているあいだだけ出る(「詳細」の前。出る条件は下の「目の撮影」と同じ)。準備を始める前(準備が済んだことがなく、まだ ① か ② の段で、このパネルを起動してから[はじめる]を押していないとき。`eyecam::introShown`)は、「目のカメラでできること」(見開き・瞳孔の大きさ・Valve がまぶたを低く読んだときにカメラで確かめて直す)、1 回だけの準備と目の映像は Frame の外に出さないことの箱、[はじめる(約 2 分)]、「使わなくても、目の送信はふつうに動きます」。押したあと(と準備が進んでいるとき)は準備のチェックリスト(いまの段階は、満たしていない最初のもの。毎回 status.json とパスワードの確認だけで決める): ① パスワードを決める(SteamOS にパスワードが無いと分かっているときだけ。`steamos-passwd --has-password` を裏で数秒おきに確かめる。読むだけ。[Konsole で passwd を開く])② 道具を入れる([Konsole で開く]で `sudo $HOME/.local/lib/eyecam/install_grab.sh` が入力済みの Konsole を開くだけ。Enter とパスワードは本人で、パネルは sudo も passwd も実行しない。`auto_grab` が ok / trying / waiting_tracker / failed: … か、バッファを持っていれば入っている扱い。`auto_grab` が `too_old`(eyecam の安全のための下限より古く、起動されない)なら入っていない扱い。`grab_outdated` が true(更新で新しい道具が届いた)か too_old なら、見出しを「道具を入れ直す」、その下に「道具が新しくなったよ、入れ直してね」)③ 目の動きを覚える(カメラの映像は「届いてるよ」、`cam_fps` が毎秒 60 枚より少なければアクセント色で「届いてるよ(毎秒 15 枚・少なめ)」。目ごとに「見えてるよ」(status.json の `pupil_l` / `pupil_r` で瞳が 50 % 以上)・「映像はあるけど瞳が見つからない」(`locked` だけど 20 % 未満、赤)・「瞳がときどきしか見えない」(そのあいだ)・「見えてないよ」(`locked` が false)。`pupil_l` / `pupil_r` の無い古い eyecam-rec では今までどおり `locked` だけで決める。目の映像が届いているとき(`locked`)は[覚えはじめる]で `calib wear` を押せて、瞳が見つからない目があるときはその下に赤字で「HMD をかぶり直して」と出す。18 秒の間、ステップのチップ・残り秒・[やめる]をカードの中に出す。失敗したら eyecam-rec のメッセージ(数も入っている)の上に、メッセージが名前を出した目(「うまくいかなかった目」)を出す。見開きだけ取れなかったら[このまま進む][もう一度(18秒)]。片目だけ失敗して通ったとき(`calib_failed_eye` が L / R)は「左目は前の値を使うよ」(初めてなら「仮の値」)と目ごとの結果、メッセージ、[使いはじめる][もう一度(18秒)])④ 完了(「使いはじめる」)。セットアップ済みでも道具が無くなれば ② に戻り、直れば校正し直さずにふだんの画面へ。道具が古いだけなら ② には戻さず、ふだんの画面のいちばん上にお知らせのカードと[Konsole で開く](`grab_outdated`: 「道具が新しくなったよ、入れ直してね(今の道具のままでも動くよ)」、`too_old`: 赤いカードで「安全のため、道具を入れ直してね。入れ直すまでカメラは止まってて、まぶたは Valve の値だけで送ってるよ」)、左の列にも同じお知らせ(押すとこのタブ)。eyecam-rec が言わなくなれば自然に消える(`eyecam::toolState` / `toolNotice`。`--fake-eyecam …:outdated`、`waiting:auto=too_old:setup:outdated` で描く)。左の列には、準備が済むまで「次にやること・目のカメラ」、済んだ直後に緑の「目のカメラ:準備できたよ」。済んだあとのふだんの画面: いまの状態(両目・片目・Valve の値とその理由。ヒントは「オン・オフは「基本」タブで」。カメラのスイッチ `camera_lids` は「基本」タブに移した。オフのあいだの校正のボタンは「「基本」タブで目のカメラを「使う」にしてから」)、その下の行の右に[普段の目を覚え直す](英語は Relearn my eyes。左に「約 30 秒。そのあいだも今の値で動くよ」。eyecam-rec に `relearn` を送り、覚え始めがやり直しになって、いまの状態の右のピルが「普段の目を覚えてるよ(あと N 秒)」に戻る。覚えているあいだも、もう覚えていた目は前の値でまぶた・見開き・細めを送り続ける(`calib_state` の 4 はそのまま)。押せるのは `eyecam::relearnAllowed`: eyecam-rec の状態が新しく、idle・live・目の映像あり〔`locked`〕・`baseline` を書く eyecam-rec、`camera_lids` がオン、カメラがまぶたを動かしているか覚えている途中〔`relearnUse`〕、ほかのコマンドの返事待ちでない)、違和感があるときの[目のカメラの校正(18秒)]、細めも送るときの[ユーザー校正(最初に 1 回)](どちらも ③ と同じカードで進み、終わると結果のカード)、こんなときは。見開きの出やすさは「まぶた」タブ。`--fake-eyecam` と `--fake-password`、`--fake-camera` で描く | | 詳細 | いちばん上の切り替え(バージョン / 困ったとき / 動作確認 / ファイル。英語は Version / Having trouble / Debug tools / Files)で 1 ページずつ出す。初めて開いたときは「バージョン」、そのあとは最後に開いた小タブ(パネルを起動し直しても。下の「最後に開いた小タブ」)。ほかのタブから戻っても同じ小タブ。新しい版があるあいだは「バージョン」の後ろに小さな点。「バージョン」: 今の版(大きく)と、最後に確かめた時刻・結果(「21:10 に確認・新しい版 0.7.6 があります」。確かめられなかった・更新に失敗したときは赤で、理由は 4 行、くわしくの場所は 2 行まで)。新しい版があれば右に[0.7.6 に更新する](確認を 1 回はさむ)、その下の箱に「0.7.6 の内容」としてその版の要約を 4 行まで(日本語の画面ではリリースの「日本語:」の段落、無ければ英語。要約の無いリリースなら箱を出さない)。更新に失敗したら[もう一度][閉じる]、入れ終わったら[閉じる]。いちばん下の行に[更新履歴](下を参照)、「起動時と 1 時間ごとに確認 オン / オフ」のチップ(押すと切り替わる)、右に[今すぐ確かめる](確かめている間と更新中は押せない)。カードのいちばん下に「ささけん@の Frame アプリ」の行(下の「ほかの Frame アプリ」)。「困ったとき」: 「診断」の行(「困ったら、診断の画面のスクショを送ってね」、診断コードのチップ、[開く]で下の「診断」ページ)、「最近の記録」(新しい 3 件。時刻・何をしたか・短い理由・OK / 失敗 / 片目だけ・[見る]。下の「記録」)、「SSH なら frameeyeosc-panel --report latest」と[すべての記録(N)›]。「動作確認」: 視線の点を表示(デバッグ用、オン / オフ)と点の距離(0.3〜2.0 m、0.1 m 刻み。1.2 m くらいより奥だと開いたダッシュボードに隠れる。点がオフの間は灰色)、そのすぐ下の行に「倍率の前の向き(輪)」(ヒント「目が見ている向き」。英語は Ring: before the scale / Where your eyes look。オン / オフは点のスイッチと同じ幅で真下に並び、右に「視線の倍率が 100 % でないときだけ、点のまわりに輪で出ます」〔英語は Shown around the dot only while a gaze scale isn't 100 %〕を 2 行まで。点がオフの間は選んでいる側を枠だけにして灰色、押せない)、目のログ(ボタンの右に保存先と 60 分の上限、目のカメラを使っているときは「目のカメラの数字(映像なし)も ~/eyecam/drift/ に残します」も。記録できなかったらその理由を 3 行まで。目のカメラを使っていて eyecam-rec が driftlog を持っていれば、記録の間 eyecam のドリフトの記録も取る: 始めに `driftlog on eyes_日時`、終わりに `driftlog off`。記録の途中で eyecam-rec が起き直してドリフトの記録が止まったら、もう一度 `driftlog on`〔断られたら 30 秒おき〕。recorder.h の DriftLink)、eyecam-rec が動いているあいだは「開発用」のバッジ付きの「目の撮影」([撮影開始]で下の光の注意。ボタンの右に「目の映像が ~/eyecam/rec_日時/ に残るよ(1 回で約 2.5 GB)」)。「ファイル」: 設定ファイル・キャリブレーション・状態ファイル・記録の場所、本体の PID と動いている時間、コマンドで固定中の項目と今の値(3 行まで) | @@ -137,7 +137,7 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ - 更新の確認だけは閉じている間も動く: 起動時と 1 時間ごとに `frame-update.sh check` を裏で動かす(GitHub に行くのは、スクリプトが覚えている答えが 24 時間より古いときだけ。前回失敗していたら 1 時間)。`update_check` が false なら動かさない。[今すぐ確かめる]は 24 時間を待たずに `--force` で確かめ、`update_check` が false でも使える - [更新する]→ 確認で「更新する」を押すと `frame-update.sh install --detach` を動かす。スクリプトが SHA256SUMS で確かめてから、新しい版の `install.sh` を前回と同じオプション(`~/.config/frameeyeosc/install-args`、無ければ `--with-panel`)で、systemd のユーザーユニット `frameeyeosc-update` の中で実行する。`install.sh` がパネルを再起動しても更新は続く。パネルは `update-state.json` を 0.5 秒ごとに読んで進み具合を出す。これはパネルが動いている間ずっと(閉じている間も)続く - 目を合わせる(`gaze_fit.{h,cpp}`): [目を合わせる]か[真ん中だけ合わせ直す]を押すと、ダッシュボードを開いたまま、すぐに頭に固定した目印(`target.cpp` の絵、別のオーバーレイ `sasaken.frameeyeosc-panel.target`)を出す。目印はダッシュボード(約 1.35 m 先)より手前の 0.9 m 先なので、開いたダッシュボードの上に見える(下の「目印」)。止まるのは[やめる]を押したとき、ほかのタブを押したとき、ダッシュボードが別のページ(ほかのオーバーレイ)を 0.5 秒(`kAwaySec`)出し続けたとき。途中でダッシュボードを閉じても続き、点も出たまま。ダッシュボードを閉じたまま始まったとき(被ったときの合わせ直し)と、途中で閉じたあとは、ダッシュボードを開くと止まる([やめる]に届かないのと、目がダッシュボードに行くため)。点は正面・上・下・左・右の順で、1 点 2.5 秒(`kPointSec`、これ 1 つで速さが変わる)。点から点へ 0.35 秒でなめらかに動き、0.5 秒後に `config.json` の `gaze_capture` に `{"id": 次の番号, "target": "center", "seconds": 2, "skip": 0.3}` などを書く(本体は 2 秒測り、最初の 0.3 秒は捨てる)。輪は 2.5 秒かけて減り、測っている間だけ点の下に残りの秒数(2・1)を出す。本体が状態ファイルの `gaze_capture` で同じ id の `done` を返したら次へ。使えたサンプルが、届いたサンプル(本体が `received` で返す、目を閉じていたものも含む)の 6 割未満か 12 未満(ただし求めるのは 45 まで。ふつうの回数では 0.5.2 と同じ)(`received` を返さない 0.5.3 より前の本体なら 45 未満)、またはばらつきが 0.06 を超えたら同じ点を 3 回までやり直す(トラッカーの回数は毎秒 90〜136 のことも 15 のこともあるので、決まった数にしない)。5 点が終わったら視線の計算を確かめてから、目印に「3 秒間 目を閉じて」と 3・2・1 を出してから `target: "closed"` を 3 秒(最初の 0.5 秒は捨てる)頼み(目を閉じたサンプルも使い、視線は使わない)、左右とも正面の開き具合の 7 割より下なら「開けて OK」を 1.5 秒出して終わり。8 秒答えが無い・本体が止まった・止められた(上)・点がほとんど動かなかった・目が閉じなかった・まぶたの開け閉めの差が 0.1 未満だったら止めて理由を出す。終わったら正面の位置・上の幅・目ごとのまぶたの 4 つの値を書き、左右と下の幅は 1、傾き(`gaze_roll_deg`)は 0、目ごとの左右(`gaze_offset_x_left/right`・`gaze_gain_x_left/right`)は null にする(真ん中だけのときは正面の位置だけ)。上の幅は、この回と `panel.json` の `fit_up_gains`(直近の全部合わせの上の幅)を合わせた最大 5 回(`kUpGainFits`)の平均(`meanUpGain`)で、[元に戻す]で履歴も消す。左右と下の点は、正しい向きに十分動いたかを確かめるだけに使う。5 回のかぶりを本当の点で採点すると、かぶりをまたいで効くのはそのかぶりで測った正面だけで、左右・下の幅(下は 0.73〜1.30)と傾きを持ち越すと Valve の生の視線より悪くなった(3.36° 対 2.78°)。上の幅はほぼ同じ(0.78〜1.01、5 回の平均で上の点のずれ 2.5° → 1.7°)。前の `config.json` は起動時に一度だけ `version` 4 へ移す(`migrateFitPolicy`、`eye_behavior` にかかわらず): 傾き 0、左右・下の幅 1、目ごとの左右 null、`auto_recenter` の `"tilt"` は `"center"`。正面・上の幅・まぶたはそのまま。HMD の IPD(`Prop_UserIpdMeters_Float`)はログに出すだけ。点の距離 0.9 m(`kTargetDistanceM`)、真ん中から見た点の向き(正面・上下 15°・左右 20°)、測り方、使えるかの決まりは変わらない -- 被ったときの合わせ直し(`auto_recenter.{h,cpp}`。「被ったとき」が真ん中だけで、視線を合わせてあり、コマンドラインで値を固定していないとき): 本体の状態ファイルの `tracking` が 5 秒(`kOffSec`)以上切れてから戻ったら被ったとみなす(Frame が 10 秒より長く眠っていた間も切れていた扱い。パネルを起動したときは、最初のかぶりとして初めから待つ)。目が 3 秒(`kSettleSec`)続けて取れていて、SteamVR につながっていて、ダッシュボードが 1 秒(`kClosedSec`)閉じていたら、真ん中だけの目合わせを始める(Valve が目を閉じたまま読んでいて目のカメラがまだ温まっていないときは、温まって 1 秒あとまで、長くて 45 秒待つ)。SteamVR につながっていない間はダッシュボードが読めない(閉じていると返る)ので開いている扱いにし、閉じた 1 秒はつながったときから数える。SteamVR は起動した数秒あとに自分でダッシュボードを開くので(2026-10-14 の再起動では、パネルがつながってから 5.1 秒後)、その前に始めるとすぐ止まってしまう。そこで、パネルが SteamVR につながったあとと、Frame がスリープから戻ったあとは、ダッシュボードが一度開くまで始めず、それを閉じて 1 秒たってから始める(つながったときにもう開いていれば、開いたことにする。パネルの更新でダッシュボードから起動し直したときなど)。SteamVR がダッシュボードを開かないこともある(被ったまま、ダッシュボードを閉じた状態でパネルが起動し直したときなど)ので、つながってから・スリープから戻ってから 45 秒(`kStartupDashboardWaitSec`)たったら、開かなくても始める。ふつうに外して被り直したときは、ダッシュボードを待たない(今までどおり)。ログは `[fit] auto re-center: waiting for SteamVR's startup dashboard to open and close (up to 45 s after connecting)`(スリープからなら `after waking up`)、閉じて始まると `[fit] auto re-center: starting (SteamVR's startup dashboard was closed)`、45 秒たったら `[fit] auto re-center: no startup dashboard after 45 s, going ahead`。1 回のかぶりで 1 回だけで、失敗や止めたときはやり直さない。ただし、それでもダッシュボードが開いて止まったとき(始まった瞬間に自分で開いた、SteamVR が 45 秒より遅れて開いたなど)だけは、ダッシュボードが 1 秒閉じたらもう一度始める。やり直しは 1 回のかぶりで 2 回まで(`kDashboardRetries`。自分でダッシュボードを何度も開く人をしつこく追わない)、被り直すと数え直す。目が落ち着くまで待つのは最初と同じ。やり直すときは失敗の音を鳴らさず、タブは灰色の「ダッシュボードを開いたので止めました」「設定は変わっていません」の下に「ダッシュボードを閉じたら、もう一度合わせ直します」(やり直しを待たなくなったら消える。`--fake-fit failed-opened-retry` で描く)。記録もいつもどおり「止めた」(灰色)で、理由にその 1 文を足す(形は変わらない)。2 回やり直したあとにまた止まったら、ふつうの「止めた」のまま。パネルのボタンで始めた目合わせは、ダッシュボードで止まってもやり直さない。ログは `[fit] auto re-center: stopped by the dashboard opening; trying again once it has been closed 1 s (retry 1 of 2)`、やり直しが始まると `[fit] auto re-center: starting (retry 1 of 2)`、もうやらないときは `[fit] auto re-center: stopped by the dashboard opening; not trying again (2 retries used this wearing)` +- 被ったときの合わせ直し(`auto_recenter.{h,cpp}`。「被ったとき」が真ん中だけで、視線を合わせてあり、コマンドラインで値を固定していないとき): 本体の状態ファイルの `tracking` が 5 秒(`kOffSec`)以上切れてから戻ったら被ったとみなす(Frame が 10 秒より長く眠っていた間も切れていた扱い。パネルを起動したときは、最初のかぶりとして初めから待つ)。目が 3 秒(`kSettleSec`)続けて取れていて、SteamVR につながっていて、ダッシュボードが 1 秒(`kClosedSec`)閉じていたら、真ん中だけの目合わせを始める(Valve が目を閉じたまま読んでいて目のカメラがまだ温まっていないときは、温まって 1 秒あとまで、長くて 45 秒待つ。目のカメラの校正中は、終わるまで待つ: `[fit] auto re-center: waiting for the eye cameras' calibration to end`)。SteamVR につながっていない間はダッシュボードが読めない(閉じていると返る)ので開いている扱いにし、閉じた 1 秒はつながったときから数える。SteamVR は起動した数秒あとに自分でダッシュボードを開くので(2026-10-14 の再起動では、パネルがつながってから 5.1 秒後)、その前に始めるとすぐ止まってしまう。そこで、パネルが SteamVR につながったあとと、Frame がスリープから戻ったあとは、ダッシュボードが一度開くまで始めず、それを閉じて 1 秒たってから始める(つながったときにもう開いていれば、開いたことにする。パネルの更新でダッシュボードから起動し直したときなど)。SteamVR がダッシュボードを開かないこともある(被ったまま、ダッシュボードを閉じた状態でパネルが起動し直したときなど)ので、つながってから・スリープから戻ってから 45 秒(`kStartupDashboardWaitSec`)たったら、開かなくても始める。ふつうに外して被り直したときは、ダッシュボードを待たない(今までどおり)。ログは `[fit] auto re-center: waiting for SteamVR's startup dashboard to open and close (up to 45 s after connecting)`(スリープからなら `after waking up`)、閉じて始まると `[fit] auto re-center: starting (SteamVR's startup dashboard was closed)`、45 秒たったら `[fit] auto re-center: no startup dashboard after 45 s, going ahead`。1 回のかぶりで 1 回だけで、失敗や止めたときはやり直さない。ただし、それでもダッシュボードが開いて止まったとき(始まった瞬間に自分で開いた、SteamVR が 45 秒より遅れて開いたなど)だけは、ダッシュボードが 1 秒閉じたらもう一度始める。やり直しは 1 回のかぶりで 2 回まで(`kDashboardRetries`。自分でダッシュボードを何度も開く人をしつこく追わない)、被り直すと数え直す。目が落ち着くまで待つのは最初と同じ。やり直すときは失敗の音を鳴らさず、タブは灰色の「ダッシュボードを開いたので止めました」「設定は変わっていません」の下に「ダッシュボードを閉じたら、もう一度合わせ直します」(やり直しを待たなくなったら消える。`--fake-fit failed-opened-retry` で描く)。記録もいつもどおり「止めた」(灰色)で、理由にその 1 文を足す(形は変わらない)。2 回やり直したあとにまた止まったら、ふつうの「止めた」のまま。パネルのボタンで始めた目合わせは、ダッシュボードで止まってもやり直さない。ログは `[fit] auto re-center: stopped by the dashboard opening; trying again once it has been closed 1 s (retry 1 of 2)`、やり直しが始まると `[fit] auto re-center: starting (retry 1 of 2)`、もうやらないときは `[fit] auto re-center: stopped by the dashboard opening; not trying again (2 retries used this wearing)` - 点の間(すべって次の点で落ち着くまでの 0.5 秒と、本体が捨てる最初の 0.3 秒)にパネルやレーザーを見ても、測った値には入らない。測っている 1.7 秒のあいだにほかを見ると、ばらつき(両方向まとめた標準偏差、0.06 ≈ 2.7° まで)が増えてその点を測り直すか、短い一瞥ならわずかに平均がずれる。長くほかを見続けると、ずれた値のまま通ることがある(動いた量の確かめで大きなずれだけは止まる)。そのため、目を合わせている間のパネルは動かない(左の列の数字も変えない、下を参照) - 視線の点(デバッグ用、`gaze_dots.{h,cpp}`): `gaze_debug_dots` がオンの間、本体は処理したサンプルごとに送っている視線を 160 バイトのデータグラム(`FEOD`・版 3・フラグ〔bit 0 目ごとの視線、bit 1 視線の倍率を使っている、bit 2 眉を送っている〕・時刻・送った視線 6 つ・なめらかにする前の視線 6 つ・目ごとの値・倍率を掛ける前の視線 6 つ、リトルエンディアン。上の「目のプレビュー」と同じもの。`src/dots.rs`)で状態ファイルのフォルダの `gaze-dots.sock` に送る。パネルはそこに Unix データグラムソケットを開き(古いファイルは消してから)、届いた最新の 1 つで点を置く。まとめた視線なら 1 つ(明るい灰色)、`independent_eyes` なら目ごとに 2 つ(左は水色、右はオレンジ。明るさも違う)で、それぞれの目の位置(IPD の半分ずつ左右)からその目の向きに 1 m 先(下を参照)。視線の倍率を使っている(4 つのどれかが 100 % でない。本体がフラグの bit 1 で知らせる)あいだは、倍率を掛ける前の視線(目が実際に見ている向き)を同じ色の輪で同じように置く(`renderGazeRing`。中が抜けた輪で、点の 1.6 倍の幅〔`kRingWidthScale`〕なので、倍率の前と後が同じ向き〔正面や 100 % の側〕なら点が輪の中にすっぽり入り、倍率で離れるほど点が輪から出ていく)。点が送る視線、輪が目の向きなので、「アバターの目がどこを向くか」と「自分の目がどこを向くか」を同時に見比べられる。倍率がどれも 100 % のときは前と後がまったく同じなので輪は出さない(点に重なるだけで、0.7.7 までと同じ見た目)。輪は既定でオンで、「動作確認」の「倍率の前の向き(輪)」で消せる(`gaze_debug_rings`、既定 `true`。点の距離と同じくパネルだけが使い、本体は使わない)。オフなら輪は出さず、点だけ(0.7.7 までと同じ見た目)。オンの間に切り替えると、次のサンプルを待たずにその回のループで輪のオーバーレイ(dot2/3)を隠す(古い輪が残らない)。点は別々のオーバーレイ(点は `sasaken.frameeyeosc-panel.dot0/1`、輪は `dot2/3`、点の幅 3.5 cm)で、絵は 1 回だけ送り、あとは位置(transform)だけ動かす。点の距離(目からその目の向きに沿って)は `gaze_debug_dots_distance_m`(既定 1.0 m、0.3〜2.0 m。パネルだけが使い、書き換えるとすぐ反映)で、ダッシュボードを開いても閉じても同じなので跳ばない。点は何も付けない普通のオーバーレイ(frame-perf-overlay のパネルと同じ)。並び順(`SetOverlaySortOrder`)や `VisibleInDashboard` を付けると 1 m でもダッシュボードに隠れた。普通のオーバーレイなら、約 1.35 m 先のパネルの上に 0.45・0.8・1.0 m で見え、1.2 m では頭の位置によって隠れた。幅は距離に比例させて(2 m なら 3.5 cm、1 m で 1.75 cm)、見た目の大きさ(約 1°)は変えない。向きは同じ光線の上なので、左右の点が指す方向は変わらない。ただし左右の点が重なるのは、両目が寄って交わる点(見ているところ)の距離に出したときだけ。それより手前なら左右の点は離れて見え、向こうなら左右が入れ替わって離れて見える(例えば 2 m 先を見ているとき、1 m の点は IPD の半分、約 3 cm 離れる。目合わせの目印と同じ 0.9 m 先を見ているときは、既定の 1 m の点は交わる点の少し向こうなので、左目の点が右に、約 7 mm 離れる)。1 つにまとめた点(`independent_eyes` がオフ)は両目の真ん中から出すので 1 つのまま。点がオンの間は、ダッシュボードを閉じていても設定ファイルを読むので、スイッチと距離はすぐ反映する。距離が変わるたびに距離と幅をログに出す。オンの間は 1/90 秒おきに回る。1 秒届かなければ隠す。目を合わせている間とオフにしたときはソケットを閉じて点を隠す - 目合わせの音(`sounds.{h,cpp}`): 起動時に 7 つの音を正弦波 / 三角波と短い立ち上がり・減衰で作り(48 kHz モノラル 16 bit、ピーク −14 dBFS)、`$XDG_RUNTIME_DIR/frameeyeosc/sounds/` に WAV で書く。鳴らす場面は目印の見た目に合わせる: 点がすべり終わって止まったら pop、測れたら pip、測り直しなら buzz、「目を閉じて」の 3・2・1 が変わるたびに tick、目を閉じた手順が終わって「開けて OK」になったら open、終わったら done、止まったら fail。`fit_sounds` が false なら鳴らさない @@ -285,10 +285,10 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ | 8 | 本体のコマンドで固定中 | `fitKeysLocked` | | 9 | 目のデータが来ていない | 本体は動いているが `status.tracking` でない(たいていはヘッドセットを外している。状態ファイルだけでは「外している」とほかの理由を見分けられないので 1 つ) | | 10 | 合わせ直しが動いている | `fit.active()` で真ん中だけ(かぶったときの自動のものも)。先に `stop` | - | 11 | 目のカメラが使えない | `relearn` で `eyecam::relearnAllowed` でない(上の[普段の目を覚え直す]が押せない)か、eyecam-rec が `err` を返した・1 秒で返事が無かった | + | 11 | 目のカメラが使えない | `relearn` で `eyecam::relearnAllowed` でない(上の[普段の目を覚え直す]が押せない)か、eyecam-rec が `err` を返した・1 秒で返事が無かった。`recenter`・`fit`・`relearn` で目のカメラの校正中(`eyecam::calibrationUnderWay`。返事に `calibrating`) | -- 条件はボタンと同じ: 目合わせのボタンは本体が動いていて固定中でないときだけ押せ、動いている間は[やめる]しか出ない。それに加えて、目のデータが来ていなければ 9(測れないので)、`recenter` は視線を合わせてあるときだけ(被ったときの自動の合わせ直しと同じ。パネルの[真ん中だけ合わせ直す]は合わせていなくても押せる)、`relearn` は目合わせの間は待つ(目合わせがまぶたを測るため)。調べる順は 6 / 10 → 4 → 9 → 11(`relearn`)/ 8 → 5 -- 標準エラーに 1 行: `recenter: started`、`fit: busy (a fit is running; use --action stop)`、`recenter: busy (a re-center is running; use --action stop)`、`stop: nothing to stop`、`stop: stopped the eye fit`、`stop: stopped the re-center`、`open: panel shown`、`recenter: no gaze fit yet (use --action fit first)`、`fit: frameeyeosc is not running`、`recenter: no eye data (is the headset on?)`、`fit: locked (the fit's values are set on frameeyeosc's command line)`、`relearn: started (learning your relaxed eyes, about 30 s)`、`relearn: eye cameras not in use or not ready (see the panel's Eye cameras tab)`、`relearn: eye cameras not ready (no eye video)`(ほかに calibrating・recording・not reading the cameras・eyecam-rec too old for it・no answer from eyecam-rec・eyecam-rec said no)、`recenter: panel not running`、`recenter: no answer from the panel`、`bogus: unknown action (use recenter|fit|stop|relearn|open)` +- 条件はボタンと同じ: 目合わせのボタンは本体が動いていて固定中でないときだけ押せ、動いている間は[やめる]しか出ない。それに加えて、目のデータが来ていなければ 9(測れないので)、`recenter` は視線を合わせてあるときだけ(被ったときの自動の合わせ直しと同じ。パネルの[真ん中だけ合わせ直す]は合わせていなくても押せる)、`relearn` は目合わせの間は待つ(目合わせがまぶたを測るため)。目のカメラの校正中は 3 つとも 11(校正は上や下を見てもらうので、点を出すと目がそちらへ行き、測った正面もずれる。パネルの目合わせのボタンもその間はグレー)。調べる順は 6 / 10 → 11(校正中)→ 4 → 9 → 11(`relearn`)/ 8 → 5。11 にしたのは、frame-aux-shortcuts 0.4.0 が 11 の 1 行に `calibrating` があると「目のカメラの校正中です」と出すため(新しいコードだと「うまくいきませんでした」になる) +- 標準エラーに 1 行: `recenter: started`、`fit: busy (a fit is running; use --action stop)`、`recenter: busy (a re-center is running; use --action stop)`、`stop: nothing to stop`、`stop: stopped the eye fit`、`stop: stopped the re-center`、`open: panel shown`、`recenter: no gaze fit yet (use --action fit first)`、`fit: frameeyeosc is not running`、`recenter: no eye data (is the headset on?)`、`fit: locked (the fit's values are set on frameeyeosc's command line)`、`relearn: started (learning your relaxed eyes, about 30 s)`、`relearn: eye cameras not in use or not ready (see the panel's Eye cameras tab)`、`relearn: eye cameras not ready (no eye video)`(ほかに calibrating・recording・not reading the cameras・eyecam-rec too old for it・no answer from eyecam-rec・eyecam-rec said no)、`recenter: busy (the eye cameras are calibrating)`(`fit`・`relearn` も)、`recenter: panel not running`、`recenter: no answer from the panel`、`bogus: unknown action (use recenter|fit|stop|relearn|open)` - 返事の速さ: 要求は毎周受け取り、その周で答える。周の間はダッシュボードを閉じていると 0.25 秒(`kClosedPollSec`)、開いていると 0.033 秒、目合わせ中は表示のフレームごとなので、ふつうは 0.25 秒と 1 周の仕事(数 ms)以内。`relearn` は eyecam-rec の返事を待つ(ふだん数十 ms、長くて `kEyecamReplySec` = 1 秒で打ち切って 11)ので、長くて約 1.3 秒。コマンドは 2 秒で 3 - ダッシュボードが閉じていれば閉じたまま、自動の合わせ直しと同じように点を出す(点はダッシュボードと関係なく出る。目合わせ全体も閉じたまま動く)。そのあとダッシュボードを開くと、いつもどおり止まる(`Failure::DashboardOpened`)。開いていれば「目を合わせる」タブにして(別のアプリのページなら `ShowDashboard` でこのパネルに。別のページのままだと 0.5 秒で止まるため)、[やめる]が押せるようにする - 記録(`summary.json`)の `trigger` は `"command"` diff --git a/panel/src/action_socket.cpp b/panel/src/action_socket.cpp index abe1bc1..a1841d3 100644 --- a/panel/src/action_socket.cpp +++ b/panel/src/action_socket.cpp @@ -120,6 +120,12 @@ Decision decide(Action action, const State& state) { return {Code::BusyRecenter, "busy (a re-center is running; use --action stop)", Effect::None}; } if (state.fitActive) return {Code::BusyFit, "busy (a fit is running; use --action stop)", Effect::None}; + // The eye cameras calibrating: the user looks where its steps say (up and down too), so a fit's dot would + // pull the eyes away from them and measure the wrong look (the fit buttons are grey meanwhile). Told with + // the eye cameras' code and "calibrating", which frame-aux-shortcuts shows as their calibration + if (state.camerasCalibrating) { + return {Code::NoCameras, "busy (the eye cameras are calibrating)", Effect::None}; + } // The buttons are grey while frameeyeosc doesn't run; without eye data (the headset off, most often) // nothing could be measured if (!state.running) return {Code::NoFrameeyeosc, "frameeyeosc is not running", Effect::None}; diff --git a/panel/src/action_socket.h b/panel/src/action_socket.h index d8849b5..2cffbde 100644 --- a/panel/src/action_socket.h +++ b/panel/src/action_socket.h @@ -51,7 +51,8 @@ enum class Code { NoEyeData = 9, ///< frameeyeosc runs, but no eye data comes in (the headset off, or eye tracking stopped) BusyRecenter = 10, ///< a re-center is running (also the automatic one after putting the headset on) NoCameras = 11, ///< relearn: the eye cameras aren't in use or not ready (off, not set up, eyecam-rec not running - ///< or busy, no eye video), or eyecam-rec said no + ///< or busy, no eye video), or eyecam-rec said no. Also recenter, fit and relearn while the eye + ///< cameras calibrate, with "calibrating" in the text (what frame-aux-shortcuts looks for) }; /** The actions (more can follow: pause / resume sending, the eye cameras' calibration, the gaze dots). */ @@ -92,6 +93,8 @@ struct State { /** The eye cameras drive the eyelids (or learn to) and eyecam-rec reads them live, idle with the eye video * (eyecam::relearnAllowed: what the "Relearn my eyes" button needs) */ bool camerasReady = false; + /** The eye cameras are calibrating (eyecam::calibrationUnderWay): the fit buttons are grey meanwhile */ + bool camerasCalibrating = false; }; /** What the panel then does. */ @@ -115,8 +118,9 @@ struct Decision { * Decide what an action does now, with the conditions of the button it stands for: the fit buttons need frameeyeosc * running and their values not locked, and not while a fit runs (they are hidden then; only "Stop" shows). The * re-center also needs a gaze fit, like the automatic one after putting the headset on, and both need eye data. - * Relearning needs the eye cameras ready, and waits for a fit too (the fit measures the eyelids). Checked in that - * order: busy, frameeyeosc, eye data, then the action's own. + * Relearning needs the eye cameras ready, and waits for a fit too (the fit measures the eyelids). None of the three + * starts while the eye cameras calibrate (the user looks where its steps say). Checked in that order: busy (a fit, + * then the calibration), frameeyeosc, eye data, then the action's own. * @param action the action * @param state the panel's state now * @return the code, the reply's text and what to do diff --git a/panel/src/auto_recenter.cpp b/panel/src/auto_recenter.cpp index 7f2b2b0..1d17fd4 100644 --- a/panel/src/auto_recenter.cpp +++ b/panel/src/auto_recenter.cpp @@ -99,6 +99,8 @@ Step Watcher::update(double now, const Inputs& in) { } } wasRunning_ = in.running; + // (a calibration over: the next one is said again) + if (!in.camerasCalibrating) calibLogged_ = false; // How long an eye has read shut on Valve's eye server while tracked (a blink is not long enough) if (!(in.running && tracking_ && (in.valveShut[0] || in.valveShut[1]))) { shutSince_ = NAN; @@ -144,6 +146,15 @@ Step Watcher::update(double now, const Inputs& in) { } else if (in.fitActive) { step.log = disarm("an eye fit was started"); } else if (in.running && tracking_ && onFor_ >= kSettleSec && !dashboardOpen && now - closedAt_ >= kClosedSec) { + // Would start now, but the eye cameras are calibrating: the user looks where its steps say (up and down too), + // so the dot would pull the eyes away from them and measure the wrong look. Waits for its end, said once + if (in.camerasCalibrating) { + if (!calibLogged_) { + calibLogged_ = true; + step.log = "auto re-center: waiting for the eye cameras' calibration to end"; + } + return step; + } // Would start now, but SteamVR's startup dashboard is still to come: started now, it would be stopped as it // opens. Said once if (startupWait_) { diff --git a/panel/src/auto_recenter.h b/panel/src/auto_recenter.h index 10ca8be..aa78f1f 100644 --- a/panel/src/auto_recenter.h +++ b/panel/src/auto_recenter.h @@ -63,6 +63,7 @@ struct Inputs { bool valveShut[2] = {false, false}; ///< Valve's eye server reads the eye below kOpenFloor (left, right) bool camerasWarming = false; ///< the eye cameras' eyelids wait for eyecam-rec's warm-up ///< (eyecam::recenterAwaitsWarmup) + bool camerasCalibrating = false; ///< the eye cameras are calibrating (eyecam::calibrationUnderWay) }; /** What to do after an update. */ @@ -80,9 +81,11 @@ struct Step { * the dashboard stopped is tried again, kDashboardRetries times at most in a wearing (fitEnded); any other end is * final for the wearing. While Valve's eye server reads an eye shut * (it can get stuck at 0 for a whole wear) and the eye cameras are still warming up, no sample would count, so it - * waits for the warm-up (up to kCameraWaitSec): the eye camera's eyelid counts for that eye after it. Time while - * frameeyeosc is not running counts for nothing (a restart while worn does not look like putting the headset on); the time since the last - * update counts for the state seen then, so a suspend right after taking the headset off counts as off. + * waits for the warm-up (up to kCameraWaitSec): the eye camera's eyelid counts for that eye after it. While the eye + * cameras calibrate (the user looking up and down as told), it waits for the calibration to end, however long. + * Time while frameeyeosc is not running counts for nothing (a restart while worn does not look like putting the + * headset on); the time since the last update counts for the state seen then, so a suspend right after taking the + * headset off counts as off. */ class Watcher { public: @@ -146,6 +149,7 @@ class Watcher { double startupFrom_ = 0.0; ///< ...since the connect or the wake... const char* startupAfter_ = ""; ///< ...which ("connecting" / "waking up")... bool startupLogged_ = false; ///< ...and a re-center has waited for it (said once) + bool calibLogged_ = false; ///< a re-center has waited for the eye cameras' calibration running now (said once) double shutSince_ = NAN; ///< since when an eye has read shut on Valve's eye server (NaN: neither does) double holdSince_ = NAN; ///< since when this wearing's start is held back for the eye cameras (NaN: it isn't) double clearSince_ = NAN; ///< ...and since when that no longer holds (NaN: it still does) diff --git a/panel/src/eyecam.cpp b/panel/src/eyecam.cpp index 71487c6..9ff58e0 100644 --- a/panel/src/eyecam.cpp +++ b/panel/src/eyecam.cpp @@ -413,6 +413,11 @@ bool isCalib(Run run) { return run == Run::CalibWear || run == Run::CalibUser; } +bool calibrationUnderWay(const View& view) { + return (view.visible && view.status.state == State::Calibrating) || + (view.busy && isCalib(runOfCommand(view.busyCommand))); +} + bool baselineWarming(const Status& status) { return status.hasBaseline && status.baseline == "warming"; } diff --git a/panel/src/eyecam.h b/panel/src/eyecam.h index caf88ad..403d363 100644 --- a/panel/src/eyecam.h +++ b/panel/src/eyecam.h @@ -540,6 +540,16 @@ Run followRun(Run last, const Status& status); */ bool isCalib(Run run); +/** + * Whether an eye-camera calibration is under way: eyecam-rec says "calibrating" (also while it waits for the eye video, + * step -1) in a fresh status, or the panel has sent one ("calib wear" / "calib user") that it hasn't answered yet. The + * user then looks where its steps say (up and down too), so the eye fit and the re-center wait for it: their buttons + * are grey, `--action` refuses them, and the automatic re-center holds back. + * @param view the recorder (its status, `visible` and `busy` as the loop keeps them) + * @return true while one is under way + */ +bool calibrationUnderWay(const View& view); + /** * Whether eyecam-rec is learning the relaxed eyes for this wear (a newer eyecam-rec, "baseline": "warming"). * @param status the status diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index b5e9fa4..921eb7e 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -237,6 +237,7 @@ UiText makeJapanese() { t.fitIntro = "[目を合わせる]を押すと、すぐに点が出ます(ダッシュボードは開いたままで大丈夫)"; t.fitNeedsRunning = "frameeyeosc が動いているときに使えます"; t.fitLocked = "視線かまぶたの値がコマンドで固定されているので使えません"; + t.fitCamerasCalibrating = "目のカメラの校正が終わってから使えます"; t.fitRunningTitle = "点を見てください"; t.fitHowTo = "頭は動かさず、点を目で追ってください。最後は 3・2・1 のあと 3 秒間目を閉じ、チャイムで開けます"; t.fitHowToCenter = "点は真ん中に出ます。頭は動かさず見てください"; @@ -1203,6 +1204,7 @@ UiText makeEnglish() { t.fitIntro = "Press \"Fit my eyes\" and a dot shows right away (the dashboard can stay open)."; t.fitNeedsRunning = "Works while frameeyeosc is running"; t.fitLocked = "Not available: gaze or eyelid values are locked by the command line"; + t.fitCamerasCalibrating = "Works once the eye cameras' calibration is over"; t.fitRunningTitle = "Look at the dot"; t.fitHowTo = "Keep your head still and follow the dot with your eyes. At the end, after 3, 2, 1, close your eyes " "for 3 seconds and open them at the chime."; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index 2be2f99..5cf5a2d 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -243,6 +243,7 @@ struct UiText { const char* fitIntro; ///< before any fit (fitHowTo follows it) const char* fitNeedsRunning; const char* fitLocked; + const char* fitCamerasCalibrating; ///< the fit buttons grey while the eye cameras calibrate const char* fitRunningTitle; ///< while a fit runs: the one calm line ("Look at the dot")... const char* fitHowTo; ///< ...and under it, what to do (full fit) const char* fitHowToCenter; ///< (re-centering) diff --git a/panel/src/main.cpp b/panel/src/main.cpp index c3ee8ed..b7954f0 100644 --- a/panel/src/main.cpp +++ b/panel/src/main.cpp @@ -3909,6 +3909,7 @@ int runOverlay(const Options& options) { in.valveShut[eye] = model.status.hasRaw && model.status.openness.v[eye] < auto_recenter::kOpenFloor; } in.camerasWarming = eyecam::recenterAwaitsWarmup(model.eyecam, view.flag(key::kCameraLids)); + in.camerasCalibrating = eyecam::calibrationUnderWay(model.eyecam); // On a clock that runs through suspend: taking the headset off usually lets the Frame sleep const auto_recenter::Step step = recenter.update(bootSeconds(), in); if (!step.log.empty()) std::fprintf(stderr, "[fit] %s\n", step.log.c_str()); @@ -3939,6 +3940,9 @@ int runOverlay(const Options& options) { eyecam::relearnAllowed(eyecam::tabVisible(model.eyecam.status, unixNow()), model.eyecam.status, lids, relearnUse(cameraUse(model.status, lids, warming))); } + // (eyecam-rec's status is read every kEyecamReadSec while it runs, and a calibration the panel sent counts + // from the moment it went out) + state.camerasCalibrating = eyecam::calibrationUnderWay(model.eyecam); action_socket::Decision decision = action_socket::decide(request.action, state); std::fprintf(stderr, "[action] %s (from PID %d): %s\n", action_socket::actionName(request.action), request.peerPid, decision.text.c_str()); diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index 7872595..5aaeff6 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -2761,7 +2761,9 @@ void EyePanel::drawEyeFit(const Pen& pen, const UiText& t, const PanelModel& m, drawEyeFitRunning(pen, t, fit); return; } - const bool canRun = m.status.running && !anyLocked; + // (not while the eye cameras calibrate: the user looks where its steps say, up and down too) + const bool camerasCalibrating = eyecam::calibrationUnderWay(m.eyecam); + const bool canRun = m.status.running && !anyLocked && !camerasCalibrating; const AutoRecenter rewear = autoRecenter(m.config); // The one button to press: fit (again); and the re-wear fit (the middle dot), the one auto_recenter runs by itself @@ -2872,6 +2874,13 @@ void EyePanel::drawEyeFit(const Pen& pen, const UiText& t, const PanelModel& m, } break; } + // The eye cameras calibrating: why the buttons are grey comes first (the rest follows it) + if (camerasCalibrating) { + if (!title.empty()) paragraphs.insert(paragraphs.begin(), title); + title = t.fitCamerasCalibrating; + error = false; + check = false; + } // Folded down to one line while "Fine-tune" is open if (compact) { if (title.empty()) title = t.fitNotYet; diff --git a/panel/tests/action_socket_test.cpp b/panel/tests/action_socket_test.cpp index 655b81f..617e1f4 100644 --- a/panel/tests/action_socket_test.cpp +++ b/panel/tests/action_socket_test.cpp @@ -205,6 +205,41 @@ void testDecide() { CHECK(relearnAnswer(false, "no reply in time").text == "eye cameras not ready (no answer from eyecam-rec)"); CHECK(relearnAnswer(false, "connect /run/user/1000/eyecam/ctl.sock: No such file or directory").code == Code::NoCameras); + // The eye cameras calibrating (the user looks up and down for it): none of the three starts, told with the eye + // cameras' code and "calibrating" (what frame-aux-shortcuts names as their calibration) + State calibrating = ready; + calibrating.camerasCalibrating = true; + calibrating.camerasReady = true; + for (const Action a : {Action::Recenter, Action::Fit, Action::Relearn}) { + d = decide(a, calibrating); + CHECK(d.code == Code::NoCameras && d.effect == Effect::None && + d.text == "busy (the eye cameras are calibrating)"); + } + CHECK(formatReply(decide(Action::Recenter, calibrating).code, decide(Action::Recenter, calibrating).text) == + "11 busy (the eye cameras are calibrating)\n"); + // ...ahead of frameeyeosc, eye data, the lock and the missing gaze fit (it is a wait, not a setting) + State calibratingStopped = calibrating; + calibratingStopped.running = false; + State calibratingBlind = calibrating; + calibratingBlind.tracking = false; + State calibratingLocked = calibrating; + calibratingLocked.locked = true; + State calibratingUnfitted = calibrating; + calibratingUnfitted.gazeFitted = false; + for (const State& s : {calibratingStopped, calibratingBlind, calibratingLocked, calibratingUnfitted}) { + CHECK(decide(Action::Recenter, s).code == Code::NoCameras && decide(Action::Fit, s).code == Code::NoCameras); + } + // ...but after a fit already running (its "Stop" first); stop and open as before + State calibratingFitting = calibrating; + calibratingFitting.fitActive = true; + CHECK(decide(Action::Recenter, calibratingFitting).code == Code::BusyFit); + CHECK(decide(Action::Stop, calibratingFitting).effect == Effect::StopFit); + CHECK(decide(Action::Stop, calibrating).code == Code::NothingToStop); + CHECK(decide(Action::Open, calibrating).effect == Effect::ShowPanel); + // Over: as before + calibrating.camerasCalibrating = false; + CHECK(decide(Action::Recenter, calibrating).effect == Effect::StartRecenter); + CHECK(decide(Action::Relearn, calibrating).effect == Effect::Relearn); // Open always opens; in any state d = decide(Action::Open, State()); CHECK(d.code == Code::Accepted && d.effect == Effect::ShowPanel); @@ -218,7 +253,7 @@ void testDecide() { static_cast(Code::BusyRecenter) == 10 && static_cast(Code::NoCameras) == 11); // Every result has its text (relearn's two, the rest from decide) for (const Action a : {Action::Recenter, Action::Fit, Action::Stop, Action::Relearn, Action::Open}) { - for (int bits = 0; bits < 128; ++bits) { + for (int bits = 0; bits < 256; ++bits) { State any; any.running = bits & 1; any.tracking = bits & 2; @@ -227,6 +262,7 @@ void testDecide() { any.fitActive = bits & 16; any.fitIsRecenter = bits & 32; any.camerasReady = bits & 64; + any.camerasCalibrating = bits & 128; const Decision each = decide(a, any); CHECK(!each.text.empty() && static_cast(each.code) != 2 && static_cast(each.code) != 3); CHECK((each.code == Code::Accepted) == (each.effect != Effect::None)); @@ -421,6 +457,13 @@ void testServer() { State cameras = ready; cameras.camerasReady = true; CHECK(roundTrip(server, path, "relearn", cameras, line, effect) == 0 && effect == Effect::Relearn); + // The eye cameras calibrating: 11, with "calibrating" in the line the command prints + State calibrating = cameras; + calibrating.camerasCalibrating = true; + CHECK(roundTrip(server, path, "recenter", calibrating, line, effect) == 11 && effect == Effect::None); + CHECK(line == "recenter: busy (the eye cameras are calibrating)"); + CHECK(roundTrip(server, path, "fit", calibrating, line, effect) == 11 && effect == Effect::None); + CHECK(roundTrip(server, path, "relearn", calibrating, line, effect) == 11 && effect == Effect::None); State locked = ready; locked.locked = true; CHECK(roundTrip(server, path, "recenter", locked, line, effect) == 8); diff --git a/panel/tests/auto_recenter_test.cpp b/panel/tests/auto_recenter_test.cpp index a27f55a..aab1a12 100644 --- a/panel/tests/auto_recenter_test.cpp +++ b/panel/tests/auto_recenter_test.cpp @@ -789,6 +789,58 @@ void testCameraWarmupOtherwiseAsBefore() { CHECK(blink.starts == 1 && blink.startedAt <= 103.25 && blink.logged("waiting") == 0); } +void testCameraCalibHolds() { + // The eye cameras calibrating when it would start (the dashboard closed during the calibration): it waits for the + // end however long, saying so once, and starts right after it + Run run; + run.in.camerasCalibrating = true; + run.wait(3.5); + CHECK(run.starts == 0 && run.watcher.armed()); + CHECK(run.logged("auto re-center: waiting for the eye cameras' calibration to end") == 1); + run.wait(120); + CHECK(run.starts == 0 && run.watcher.armed() && run.logs.size() == 1); + run.in.camerasCalibrating = false; + const double over = run.now; + run.wait(0.5); + CHECK(run.starts == 1 && run.startedAt <= over + 0.25); + CHECK(run.logs.size() == 2 && run.logs[1] == "auto re-center: starting"); + // Calibrated with the dashboard open (from the panel's Eye cameras tab), as usual: the end changes nothing until + // the dashboard has been closed kClosedSec + Run dash; + dash.in.dashboardOpen = true; + dash.in.camerasCalibrating = true; + dash.wait(20); + dash.in.camerasCalibrating = false; + dash.wait(5); + CHECK(dash.starts == 0 && dash.logged("calibration") == 0); + dash.in.dashboardOpen = false; + const double closed = dash.now; + dash.wait(2); + CHECK(dash.starts == 1 && dash.startedAt >= closed + auto_recenter::kClosedSec); + // ...and closed again mid-calibration: it waits for that calibration + Run mid; + mid.in.dashboardOpen = true; + mid.in.camerasCalibrating = true; + mid.wait(5); + mid.in.dashboardOpen = false; + mid.wait(10); + CHECK(mid.starts == 0 && mid.logged("calibration to end") == 1); + mid.in.camerasCalibrating = false; + mid.wait(0.5); + CHECK(mid.starts == 1); + // A second calibration in a later wearing is said again + mid.finishFit(); + mid.in.tracking = false; + mid.wait(20); + mid.in.tracking = true; + mid.in.camerasCalibrating = true; + mid.wait(10); + CHECK(mid.starts == 1 && mid.logged("calibration to end") == 2); + mid.in.camerasCalibrating = false; + mid.wait(0.5); + CHECK(mid.starts == 2); +} + void testWornFor() { // For the records: how long the eyes have been tracked, and how long they weren't before that Run run; @@ -832,6 +884,7 @@ int main() { testCameraWarmupHolds(); testCameraWarmupCap(); testCameraWarmupOtherwiseAsBefore(); + testCameraCalibHolds(); testWornFor(); if (gFailures == 0) std::printf("auto-recenter-test: all passed\n"); return gFailures == 0 ? 0 : 1; diff --git a/panel/tests/eyecam_test.cpp b/panel/tests/eyecam_test.cpp index 25befbe..697e9ff 100644 --- a/panel/tests/eyecam_test.cpp +++ b/panel/tests/eyecam_test.cpp @@ -582,6 +582,43 @@ void testCalib() { CHECK(eyecam::isCalib(Run::CalibWear) && eyecam::isCalib(Run::CalibUser)); CHECK(!eyecam::isCalib(Run::Recording) && !eyecam::isCalib(Run::None)); + // A calibration under way (the fit and the re-center wait for it): eyecam-rec says "calibrating" in a fresh status, + // also while it waits for the eye video (step -1), or the panel sent one it hasn't answered yet + { + eyecam::View view; + view.visible = true; + view.status = calibStatus("calibrating", 1, ", \"step_index\": -1, \"protocol\": \"calib user\""); + CHECK(eyecam::calibrationUnderWay(view)); + view.status = calibStatus("calibrating", 1, ", \"step_index\": 2, \"step_label\": \"look_up\""); + CHECK(eyecam::calibrationUnderWay(view)); + view.status = calibStatus("calibrating", 0, ", \"protocol\": \"calib wear\""); + CHECK(eyecam::calibrationUnderWay(view)); + // A stale status (eyecam-rec gone) says nothing + view.visible = false; + CHECK(!eyecam::calibrationUnderWay(view)); + view.visible = true; + // Anything else: no (relearning's warm-up too: idle, the eyes as usual) + for (const char* other : {"idle", "recording", "searching", "error", "waiting_fds", "stopped"}) { + view.status = calibStatus(other, 3, ", \"baseline\": \"warming\""); + CHECK(!eyecam::calibrationUnderWay(view)); + } + // Sent and not answered yet: a calibration counts, other commands don't + view.busy = true; + for (const char* calib : {"calib wear", "calib user"}) { + view.busyCommand = calib; + CHECK(eyecam::calibrationUnderWay(view)); + } + for (const char* other : {"relearn", "start", "stop", "set lid_veto on", ""}) { + view.busyCommand = other; + CHECK(!eyecam::calibrationUnderWay(view)); + } + view.busyCommand = "calib user"; + view.visible = false; + CHECK(eyecam::calibrationUnderWay(view)); + view.busy = false; + CHECK(!eyecam::calibrationUnderWay(view)); + } + // What ran last, following the status const auto step = [](const std::string& state, const std::string& label) { return eyecam::parseStatus("{\"state\": \"" + state + "\", \"step_label\": \"" + label + "\"}", kNow); diff --git a/panel/tests/panel_test.cpp b/panel/tests/panel_test.cpp index e072ceb..638b63d 100644 --- a/panel/tests/panel_test.cpp +++ b/panel/tests/panel_test.cpp @@ -2023,6 +2023,24 @@ void testFitTabPolicy(const FontSet& fonts) { } // Everything on the page stays inside the card for (const EyePanel::HitArea& area : panel.hitAreas()) CHECK(area.y + area.h <= 680); + // While the eye cameras calibrate (the user looks up and down for it), neither fit can be pressed; the rest of the + // page can. Also from the moment the panel sent the calibration, before eyecam-rec says so + panel.setFitDetails(false); + m.eyecam.visible = true; + m.eyecam.status.state = eyecam::State::Calibrating; + panel.render(m); + CHECK(hits(panel, PanelAction::FitStart).empty() && hits(panel, PanelAction::FitCenter).empty()); + CHECK(hits(panel, PanelAction::SetAutoRecenter).size() == 2 && hits(panel, PanelAction::FitDetails).size() == 1); + m.eyecam.status.state = eyecam::State::Idle; + m.eyecam.busy = true; + m.eyecam.busyCommand = eyecam::kCalibUserCommand; + panel.render(m); + CHECK(hits(panel, PanelAction::FitStart).empty() && hits(panel, PanelAction::FitCenter).empty()); + // Over: back + m.eyecam.busy = false; + m.eyecam.busyCommand.clear(); + panel.render(m); + CHECK(hits(panel, PanelAction::FitStart).size() == 1 && hits(panel, PanelAction::FitCenter).size() == 1); } /** From 6dbbdc751cba48f3deb81428be43f8e6ad67d143 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sat, 24 Oct 2026 18:48:08 +0900 Subject: [PATCH 30/65] =?UTF-8?q?eyecam:=20=E6=AD=A2=E3=81=BE=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E7=9B=AE=E3=81=AE=E6=98=A0=E5=83=8F=E3=81=8C=E6=88=BB?= =?UTF-8?q?=E3=81=A3=E3=81=9F=E3=81=A8=E3=81=8D=E3=81=A8=E3=80=81=E8=BF=91?= =?UTF-8?q?=E6=8E=A5=E3=82=BB=E3=83=B3=E3=82=B5=E3=83=BC=E3=81=8C=E3=81=8B?= =?UTF-8?q?=E3=81=B6=E3=81=A3=E3=81=9F=E3=81=AB=E5=A4=89=E3=82=8F=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E3=81=A8=E3=81=8D=E3=82=82=E3=80=81=E6=96=B0=E3=81=97?= =?UTF-8?q?=E3=81=84=E3=81=8B=E3=81=B6=E3=82=8A=E3=81=A8=E3=81=97=E3=81=A6?= =?UTF-8?q?=E8=A6=9A=E3=81=88=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2026-10-24: 再起動のあと HMD をかぶらないうちに映像をつかみ(proximity 15.2)、空の HMD の絵から 5 分間 探す範囲(x 117 から)・明るさ・虹彩の半径を覚えた。かぶったときの映像の途切れが 5 秒で NEW_WEAR_GAP(10 秒)より短く、 覚え直さないまま校正して、瞳が 486 枚中 88/5・130/13・162/85 枚しか見つからなかった(新しいエンジンなら 486/486) - eyecam-rec が Msg::NewWear を送る: idle で「searching again」のあと映像が戻ったとき(途切れの長さによらず)、 録画・校正中の再ロック、近接センサーが 10 秒以上「かぶっていない」のあと「かぶった」に変わったとき - livesvc は、その前の 10 秒の映像でどちらかの目の瞳が半分以上見えていたら(かぶったまま。センサーはかぶっていても 上・下を見たり見開いたりすると 1〜2 秒 0〜15 に落ちる: 開発者の校正の status.jsonl)と、校正中は覚え直さない。 センサーが「かぶっていない」間もエンジンには映像を渡す(低く読む人の追跡を止めないため) - 確認: ライブの worker に暗い空の HMD の映像を 5 分、5 秒止めてから calib_2026-10-24_17-53-32 を流すと、 今まで L 482 / R 97、NewWear ありで 486 / 486。開発者の dev 校正 20 回の replay は同じ値。cargo test 128 + 10 - README・更新履歴(英・日)に書く Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.ja.md | 1 + CHANGELOG.md | 1 + tools/eyecam/README.md | 8 +- tools/eyecam/src/bin/eyecam-rec.rs | 28 ++++++ tools/eyecam/src/livesvc.rs | 145 +++++++++++++++++++++++++---- 5 files changed, 162 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 66a83e3..8521bd4 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -11,6 +11,7 @@ SteamOS が開いた片目を閉じていると読み続けても、視線が止 - eyecam、ユーザー校正: 細めは、そのかぶりの「開いた〜閉じた」の幅に対する割合で保存されていたのに、使うときは普段の幅に掛けていました。この幅はかぶるたびに 0.6〜1.5 倍違うので、校正した細めの深さが 0.65〜1.5 倍ずれていました。今はユーザー校正で細めの深さそのものも保存し(各目のユーザーの値の `sq_depth`)、使うときもそれを、校正と同じ上限・下限の中で使います。案内に沿った「普通」の段で開いた目が細めと読まれたのは 2.4 % から 0.8 % になり、いちばん細めたときは少し低めに読まれます(0.9 以上が 53 % から 35 %)。新しくユーザー校正をしたあとから効き、前の校正は今まで通り動きます。細めが浅すぎると感じたら、ユーザー校正をやり直してください。前の版に戻してそこで校正を保存すると `sq_depth` が消えるので、更新したあとにユーザー校正をやり直してください - eyecam、度付きインサート: 目のカメラの前にインサートがあると、閉じた目が少し開いていると読まれたり、虹彩が大きすぎると測られて閉じが見えにくくなったりしていました。閉じた目で見つけた下まぶたの線が、開いていたときより虹彩の半径の 0.9 倍以上下にあるときや、探す範囲の下端に張り付いているときは使わず、最後に瞳が見えていたときの線を使います(下まぶたはまばたきでほとんど動かないため)。瞳が欠けずに見えているのに上まぶたの線が瞳の中心より下にあるときも、その線は使いません。そういうかぶりの校正のサンプルで測り直すと、左目の「開いた〜閉じた」の幅は 0.30 から 1.35 になりました(右目は 1.31)。かぶりの虹彩の半径が開いた瞳の 4 倍を超えたら、虹彩の縁をいちばん強い明るさの変わり目ではなく、虹彩から出て最初の立ち上がりで測り、半径と開いた目の iris box の明るさを覚え直します。半径を 61 / 54 px から 79 / 75 px に引っぱる帯を足した録画 26 本で流し直すと、半径は 72 / 67 px、閉じの取りこぼしは 1 分あたり 1.27 回から 1.08 回、0.2 秒未満のまばたきは 80 % から 83 % 取れるようになり、そのかわり開いた目が半目と読まれたのは 5.3 % から 6.4 % になりました。元の録画では半径の切り替えは起きず、案内に沿った閉じが取れたのは 97.3 % から 98.2 % で、ほかはほぼ同じでした(開いた目が半目と読まれたのは 5.3 % から 5.4 %)。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください - eyecam、度付きインサートと見開き: 白目が明るく照らされていてインサートの反射もあると、上まぶたの線をその明るいところの上の縁(瞳の中心より下か、すぐ上)で取ってしまうことが多く、かぶりの校正では見開きの高さを長い上まつ毛の先で取っていました。そのため見開きの幅が普段の 3〜4 倍になって見開きに届かず、開いた目は細めに読まれていました。瞳が欠けずに見えているのに線が瞳の上半分より下にあるときは、瞳の上でもう一度探し、見つかった線が元の線と同じくらい以上はっきりしていればそちらを使います。見開きの高さを上の範囲から取るときに、その範囲で普段の段にも線があれば、その線が上がった分を見開きの幅にします。虹彩の半径の 1.1 倍を超える見開きの幅は見開きが取れなかったものとして扱い(いつもの幅を使います)、すでに保存されている 0.9 倍を超える幅は履歴の中央値に入れません。そういう人の録画で流し直すと、見開きの幅は 1.30 / 1.15 から 0.52 / 0.51 になりました。同じ録画で作ったユーザー校正を当てた(甘めの)確認では、右目は見開きの間の約 90 % で見開きと読まれ(前は 0 %)、左目は 19〜76 %(前は 0〜35 %)、普段の段の細めは 0.16〜0.63 から 0〜0.07 になりました。左目の見開いた線は虹彩の上端に乗ることが多く、見開きはまだ少し低めに出ることがあります。開発者の録画では校正は同じ値で、19 本のうち 1 本でサンプルの 0.16 % が変わりました。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください。 +- eyecam: HMD をかぶっていないときに目の映像をつかむと(再起動のあとなど)、目のカメラは空の HMD の絵から瞳を探す範囲や明るさを覚えてしまい、かぶったときの映像の途切れが 10 秒より短いとそれが残って、そのあとのカメラの校正で瞳が 486 枚中 5〜162 枚しか見つかりませんでした。今は、途切れた映像が戻ったとき(途切れの長さによらず)と、近接センサーが 10 秒以上「かぶっていない」と言ったあとに「かぶった」に変わったときも、かぶり直したとみなして覚え直します。ただし、その前の 10 秒の映像でどちらかの目の瞳が半分以上見えていたとき(センサーはかぶっていても 1〜2 秒低く読むことがあります)と、校正中は覚え直しません。空の HMD の映像を 5 分流したあとにその校正を流し直すと、右目の瞳が見つかったのは 486 枚中 97 枚から 486 枚になりました - パネル: 目のカメラの校正中は、視線の合わせ直しと目合わせが始まらないようにしました。ユーザー校正の途中にほかのアプリから合わせ直しを頼まれると(frame-aux-shortcuts の 2 回タップなど)、点が出て、校正のために上を見ているときの視線の正面を保存していました。`--action recenter`・`fit`・`relearn` は 11 で終わり(1 行に「calibrating」と出ます)、その間は[目を合わせる]と[真ん中だけ合わせ直す]がグレーになり、被ったときの自動の合わせ直しは校正が終わるまで待ちます - ログ: 「Eye data has been low」の行で、取りこぼしがないときに「-0 published samples were missed」と出ていたのを、0 と出すようにしました。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a335fb..148ff84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The gaze no longer stays frozen when SteamOS keeps reading one open eye as close - eyecam, user calibration: the squint was saved as a fraction of that wear's open-to-closed gap but used against the usual gap, which differs by 0.6 to 1.5 times from wear to wear, so the calibrated squint came out 0.65 to 1.5 times as deep. The user calibration now also saves the squint's depth (`sq_depth` in each eye's user values), and live uses it, kept within the same limits as the calibration's. Open eyes read as a squint went from 2.4 % to 0.8 % in the guided normal step; a full squint reads a little lower (0.9 or more 35 % of the time instead of 53 %). This takes effect after a new user calibration; older calibrations work as before. If your squint now reads too shallow, run the user calibration again. Going back to an older version and saving a calibration there drops `sq_depth`, so run the user calibration again after updating. - eyecam, prescription inserts: with an insert in front of the eye cameras, a closed eye could read partly open, and an iris fit far too large made closes harder to see. A lower lid line found on a closed eye 0.9 iris radii or more below where it was with the eye open, or against the bottom of its search band, is no longer taken: the last one seen with the pupil is used instead (the lower lid hardly moves in a blink), and an upper lid line below the centre of a pupil seen whole is dropped. Re-fitting one such wear calibration's samples, the left eye's open-to-closed gap goes from 0.30 to 1.35 (its right eye: 1.31). When a wear's iris radius comes out over 4 times the open pupil's, the iris edge is taken at the first rise out of the iris instead of the strongest edge, and the radius and the open eye's iris-box level are learnt again. Replayed on 26 recordings with a band added that pulls the radius from 61 / 54 px to 79 / 75 px: 72 / 67 px, closes missed 1.08 instead of 1.27 a minute, blinks under 0.2 s caught 83 % instead of 80 %, but open eyes read half-shut 6.4 % of the time instead of 5.3 %. On the original recordings the radius switch never happens; guided closes were caught 98.2 % of the time instead of 97.3 %, and everything else stayed about the same (open eyes read half-shut 5.4 % of the time instead of 5.3 %). If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. - eyecam, prescription inserts and widening: with a brightly lit white of the eye and glints from an insert, the upper lid line was often taken at the bright area's upper edge, under or just above the pupil's centre, and the wear calibration's widen level at the tips of long upper lashes, so the widen step came out 3 to 4 times the usual size and widening could never be reached, while open eyes read as a squint. Now, when the pupil is seen whole and the line lies below the upper half of the pupil, the lid is searched for again above the pupil, and that line is taken if it is at least as clear. When the widen level comes from the higher search area and that area also has a line in the normal step, the widen step is how far that line rose. A widen step above 1.1 iris radii is taken as a widen not caught (the usual step is used), and steps above 0.9 already saved are left out of the history's median. Replaying one such wearer's recording, the widen step went from 1.30 / 1.15 to 0.52 / 0.51, and with a user calibration made from that same recording (so a favourable test) the right eye read as widened in about 90 % of its widen frames instead of none (the left eye 19 to 76 % instead of 0 to 35 %), and the squint in the normal step went from 0.16-0.63 to 0-0.07. The left eye's widened line often sits at the top of the iris, so its widening may still read a little low. On the developer's recordings, the calibrations came out the same, and 0.16 % of the samples in one of 19 recordings changed. If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. +- eyecam: when the eye cameras locked on while the headset was off (after a restart, say), they learnt the empty headset's picture (where to look for the pupil, its brightness), and kept it once the headset was put on if the video paused for less than 10 s; the next camera calibrations then found the pupil in only 5 to 162 of 486 frames. The headset now also counts as put on again when the eye video comes back after a pause, however short, and when the proximity sensor says worn after 10 s of not worn, unless either eye's pupil was seen in at least half of the last 10 s of video (the sensor can read low for a second or two while worn), and never during a calibration. Replaying that calibration after 5 minutes of an empty headset: the right eye's pupil found in 97 of 486 frames before, 486 now. - Panel: while the eye cameras calibrate, the gaze re-center and the eye fit no longer start. A re-center asked for by another app (a double tap in frame-aux-shortcuts, say) during a user calibration showed its dot and saved the gaze center measured while you were looking up for the calibration. `--action recenter`, `fit` and `relearn` now exit with 11 and "calibrating" in their line, "Fit my eyes" and "Re-center only" are grey meanwhile, and the automatic re-center after putting the headset on waits for the calibration to end. - Logs: the "Eye data has been low" line said "-0 published samples were missed" when none were; it now says 0. diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index e31010d..e5b1f09 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -168,7 +168,7 @@ uid 1000(steamos)前提で `/run/user/1000` は固定(`XDG_RUNTIME_DIR` | `prox` | number | 近接センサーの値(読めなければ -1)。だいたい 20 を超えるとかぶっている(顔によっては、かぶっていても 20 に届かない) | | `search` | string | 映像を探していて `locked` が false のとき、見つからない理由: `not_worn`(近接センサーが「かぶっていない」と言っていて、Valve の視線トラッカーも新しいサンプルを出しておらず、映像も見つからない。`message` は `HMD をかぶってね(目の映像を待ってるよ)`)/ `no_video`(かぶっている〔近接センサーがそう言っている・センサーが読めない・センサーは「かぶっていない」でも Valve の視線トラッカーが新しいサンプルを出している(センサーが低く読む顔がある)のどれか〕のに、バッファに目の映像が流れていない。`message` は `目の映像がまだ流れていない(SteamVR の視線トラッキングはオン?)`)/ `one_eye`(片方のカメラの映像しか見つからない。`message` は `片目しか映っていない(ちゃんとかぶれてる?)`)。ロックしているとき・探していないとき(ライブ処理がオフなど)・探し始めて最初に見るまでは `""`。このフィールドがない古い eyecam-rec もある | | `last_session_aborted` | bool | 直前の録画が最後まで行かなかった(stop など)。その meta.txt は `aborted=1` | -| `calib_state` | int | ビットの和: 1 このかぶりで `calib wear` した / 2 ユーザー校正済み(`calib user`)/ **4 自動の基準線ができた(校正しなくても `eye_wide` などが使える)**。かぶり直す(映像が 10 秒以上止まる)と 1 と 4 は落ちる。**4 は `setup_done` が true になるまで立たない**(最初の準備が済むまで frameeyeosc はカメラを使わない。基準線を覚える計算は裏で続けている) | +| `calib_state` | int | ビットの和: 1 このかぶりで `calib wear` した / 2 ユーザー校正済み(`calib user`)/ **4 自動の基準線ができた(校正しなくても `eye_wide` などが使える)**。かぶり直す(映像が 10 秒以上止まるなど)と 1 と 4 は落ちる。**4 は `setup_done` が true になるまで立たない**(最初の準備が済むまで frameeyeosc はカメラを使わない。基準線を覚える計算は裏で続けている) | | `recalib_suggested` | bool | 今は常に false(基準線がかぶっている間ずっと追従するので、やり直しをすすめる必要がなくなった。互換のため残している) | | `baseline` | string | 自動の基準線(ふだんの目の開き): `warming`(覚えているところ。`eye_wide` は 0)/ `ready`。かぶり直すと `warming` に戻る | | `warmup_remaining_s` | number | `warming` のとき、あと何秒ぶんの「使えるフレーム」(目を開けて、ふつうに前を見ている)が要るか。`ready` なら 0。かぶってから約 35 秒で使えるようになる | @@ -660,7 +660,11 @@ python3 ~/eyecam-src/tools/drift_report.py ~/eyecam/drift/drift_….csv # 5 L と R が逆なので、読むときに入れ替える(目ごとの値、`failed_eye`、メッセージの左右)。calib.json は起動したときに一度だけ 入れ替えて、印を付けて保存し直す - 映像が 10 秒以上止まったら(HMD を外したら)新しいかぶりとみなして、かぶりの校正と学習した値(レンズの端、虹彩の半径、開いた目の明るさ、 - 基準線)を捨てて、もう一度覚える + 基準線)を捨てて、もう一度覚える。止まった映像が戻ったとき(止まっていた長さによらない)と、近接センサーが 10 秒以上「かぶっていない」と + 言ったあと「かぶった」に変わったときも同じ。ただし、その前の 10 秒の映像でどちらかの目の瞳が半分以上見えていたら(かぶったままだった。 + センサーはかぶっていても上・下を見たり見開いたりすると 1〜2 秒 0〜15 に落ちる)と、校正中はそのまま。HMD を外したまま起動すると、 + 外した HMD の絵から覚えた値(探す範囲が x 117 から、コントラスト 33)がかぶったあとも残り、かぶりの校正で瞳が 486 枚中 5〜162 枚しか + 見つからなかったため(2026-10-24) - 校正を始めるときは、瞳を探す目安(前の瞳の位置)を消してから始める(かぶりの学習値はそのまま) ### 試作(Python)との一致 diff --git a/tools/eyecam/src/bin/eyecam-rec.rs b/tools/eyecam/src/bin/eyecam-rec.rs index 79df2be..c641c80 100644 --- a/tools/eyecam/src/bin/eyecam-rec.rs +++ b/tools/eyecam/src/bin/eyecam-rec.rs @@ -988,6 +988,10 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m let mut deferred: Vec = Vec::new(); let mut next_check = 0.0; let mut next_search = 0.0; + // The eye frames stopped and have not come back yet, and since when the proximity sensor says not worn: either + // ending may be a new wear (livesvc::NEW_WEAR_OFF_S). + let mut came_back = false; + let mut prox_off_since: Option = None; let waiting = || if live.on { status::search_message("") } else { "待機中" }; let mut state: (&'static str, String) = ("idle", waiting().into()); // The buffers are held from here on: say so right away (the first search can take a while). @@ -1106,6 +1110,8 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m pump = Some(Pump::new(&b.arenas[r.arena], r)); } next_check = 0.0; + // The sensor was not watched meanwhile. + prox_off_since = None; } let now = now_raw(); @@ -1115,6 +1121,7 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m if !p.poll(&b.arenas[p.ring.arena], now, args.swap, live) { eprintln!("eye frames stopped (idle); searching again"); pump = None; + came_back = true; live.send(Msg::Stale); next_search = now + 1.0; if state.0 == "idle" { @@ -1125,10 +1132,16 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m } else if now >= next_search { if let Some(r) = cached.take().filter(|r| ring_is_live(&b.arenas[r.arena], r)) { pump = Some(Pump::new(&b.arenas[r.arena], r)); + if std::mem::take(&mut came_back) { + live.send(Msg::NewWear("the eye frames came back")); + } } else { match lock_ring(b, args, prox, Some(d), Ctx::Idle, &mut || true, &mut deferred) { Ok(r) => { pump = Some(Pump::new(&b.arenas[r.arena], r)); + if std::mem::take(&mut came_back) { + live.send(Msg::NewWear("the eye frames came back")); + } if state.0 == "idle" && state.1 == status::search_message("") { state.1 = "待機中".into(); } @@ -1150,6 +1163,17 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m if let Err(a) = b.watch(reading.map(|v| v > args.prox_min)) { return Some(a); } + match reading { + Some(v) if v > args.prox_min => { + if prox_off_since.take().is_some_and(|off| now - off >= livesvc::NEW_WEAR_OFF_S) && live.on { + live.send(Msg::NewWear("the proximity sensor says worn again")); + } + } + Some(_) => { + prox_off_since.get_or_insert(now); + } + None => prox_off_since = None, + } let locked = pump.as_ref().is_some_and(|p| now - p.last_change < 1.0); let searching = live.on && pump.is_none(); d.set(|s| { @@ -2411,6 +2435,10 @@ fn record( } slots = SlotStates::new(&b.arenas[ring.arena], &ring); last_change = now_raw(); + // A calibration keeps its wear (livesvc::NEW_WEAR_OFF_S). + if let Some(l) = live.as_deref() { + l.send(Msg::NewWear("the eye frames came back")); + } } Err(Abort::Stopped) => break "stopped".into(), Err(Abort::TimeUp) => break "done".into(), diff --git a/tools/eyecam/src/livesvc.rs b/tools/eyecam/src/livesvc.rs index 5010672..0300563 100644 --- a/tools/eyecam/src/livesvc.rs +++ b/tools/eyecam/src/livesvc.rs @@ -13,6 +13,19 @@ use std::time::{Duration, Instant}; /// A new wear (HMD taken off and put back on) when an eye's frames stop for this long. const NEW_WEAR_GAP: f64 = 10.0; +/// eyecam-rec also says when a new wear may have begun (`Msg::NewWear`): the eye frames came back after they stopped, +/// however short the gap, or the proximity sensor says worn after NEW_WEAR_OFF_S of not worn. It is taken as one +/// (as after NEW_WEAR_GAP) unless either eye had the pupil in at least NEW_WEAR_KEEP_SHARE of its frames over the last +/// NEW_WEAR_LOOK_S before it: the eyes were there, so the HMD was worn all along (the sensor reads 0 to 15 for a +/// second or two while worn, looking up, down or widening: the developer's calibrations of 2026-10-24), or the frames +/// only paused. A calibration running keeps its wear. 2026-10-24: after a restart the frames locked with the HMD off +/// (proximity 15.2) and the engines learnt the lens edge, levels and iris radius from 5 minutes of an empty HMD +/// (search window from x 117 instead of 180, contrast 33 instead of 85, the closed-lid check refusing the pupil); +/// the frames stopped for 5 s when it was put on, under NEW_WEAR_GAP, and three wear calibrations found the pupil +/// in 88 / 5, 130 / 13 and 162 / 85 of 486 normal frames (486 / 486 from a fresh engine). +pub const NEW_WEAR_OFF_S: f64 = 10.0; +const NEW_WEAR_LOOK_S: f64 = 10.0; +const NEW_WEAR_KEEP_SHARE: f64 = 0.5; /// An eye's published values are marked invalid when its frames stop for this long. const STALE_AFTER: f64 = 0.25; /// status.json's pupil_l / pupil_r: the share of frames with the pupil over this many seconds; cam_fps: the frames @@ -112,6 +125,8 @@ pub enum Msg { Abort(Option), /// Frames stopped (lost lock or no buffers): mark the outputs invalid now. Stale, + /// A new wear may have begun (NEW_WEAR_OFF_S); why, for the log. + NewWear(&'static str), SetLive(bool), /// Whether eyecam-rec holds the camera buffers (the shared memory's `live` needs both this and live on). Buffers(bool), @@ -287,14 +302,20 @@ struct PupilSeen { } impl PupilSeen { - fn push(&mut self, t: f64, ok: bool) -> f64 { + /// Add a frame and return the share with the pupil over the last `span` s. + fn push(&mut self, t: f64, ok: bool, span: f64) -> f64 { self.recent.push_back((t, ok)); self.seen += ok as usize; - while self.recent.front().is_some_and(|x| t - x.0 > PUPIL_SHARE_S) { + while self.recent.front().is_some_and(|x| t - x.0 > span) { let (_, old) = self.recent.pop_front().unwrap(); self.seen -= old as usize; } - self.seen as f64 / self.recent.len() as f64 + self.share() + } + + /// The share with the pupil over the frames kept (0 without any). + fn share(&self) -> f64 { + if self.recent.is_empty() { 0.0 } else { self.seen as f64 / self.recent.len() as f64 } } } @@ -414,6 +435,8 @@ struct Worker { flipped: Vec, pupils: [PupilTrack; 2], pupil_seen: [PupilSeen; 2], + /// The same over NEW_WEAR_LOOK_S, for `new_wear_hint`. + wear_seen: [PupilSeen; 2], pupil_offset: PupilOffset, pub pupil_conflicts: u64, pub pupil_pairs: u64, @@ -457,6 +480,7 @@ impl Worker { flipped: vec![0; W * H], pupils: [PupilTrack::default(), PupilTrack::default()], pupil_seen: [PupilSeen::default(), PupilSeen::default()], + wear_seen: [PupilSeen::default(), PupilSeen::default()], pupil_offset: PupilOffset::default(), pupil_conflicts: 0, pupil_pairs: 0, @@ -563,23 +587,53 @@ impl Worker { }); } + /// The HMD was off: a new wear. Its geometry and levels have to be learnt again. + fn new_wear(&mut self) { + for e in &mut self.engines { + e.reset(); + } + for p in &mut self.params { + p.wear = None; + } + self.pupils = [PupilTrack::default(), PupilTrack::default()]; + self.pupil_seen = [PupilSeen::default(), PupilSeen::default()]; + self.wear_seen = [PupilSeen::default(), PupilSeen::default()]; + self.pupil_offset = PupilOffset::default(); + self.last_t = [f64::NEG_INFINITY; 2]; + } + + /// eyecam-rec's hint that a new wear may have begun (NEW_WEAR_OFF_S): a new wear unless a calibration is running + /// or either eye had the pupil in NEW_WEAR_KEEP_SHARE of its last NEW_WEAR_LOOK_S of frames. Returns whether it + /// was taken as one. + fn new_wear_hint(&mut self, why: &str) -> bool { + let share = self.wear_seen.iter().map(|s| s.share()).fold(0.0, f64::max); + let kept = if self.collect.is_some() { + Some("a calibration is running".to_string()) + } else if share >= NEW_WEAR_KEEP_SHARE { + Some(format!("pupil in {:.0}% of the last {NEW_WEAR_LOOK_S} s of frames", 100.0 * share)) + } else { + None + }; + match kept { + Some(reason) => { + eprintln!("live: {why}; same wear ({reason})"); + false + } + None => { + eprintln!("live: {why}; a new wear (pupil in {:.0}% of the last {NEW_WEAR_LOOK_S} s of frames): learning it again", 100.0 * share); + self.new_wear(); + true + } + } + } + fn frame(&mut self, eye: usize, upside_down: bool, t_cam_ns: u64, pitch: f32, open: f32, data: &[u8]) { if !self.live || data.len() != W * H { return; } let t = t_cam_ns as f64 * 1e-9; if t - self.last_t[eye] > NEW_WEAR_GAP && self.last_t[eye].is_finite() { - // The HMD was off: a new wear. Its geometry and levels have to be learnt again. - for e in &mut self.engines { - e.reset(); - } - for p in &mut self.params { - p.wear = None; - } - self.pupils = [PupilTrack::default(), PupilTrack::default()]; - self.pupil_seen = [PupilSeen::default(), PupilSeen::default()]; - self.pupil_offset = PupilOffset::default(); - self.last_t = [f64::NEG_INFINITY; 2]; + self.new_wear(); } self.last_t[eye] = t; self.last_seen[eye] = Some(Instant::now()); @@ -615,7 +669,8 @@ impl Worker { self.veto_fired += o.veto.fired as u64; let started = Instant::now(); self.check_pupils(eye, t, &mut o); - let share = self.pupil_seen[eye].push(t, o.f.ok_pupil); + let share = self.pupil_seen[eye].push(t, o.f.ok_pupil, PUPIL_SHARE_S); + self.wear_seen[eye].push(t, o.f.ok_pupil, NEW_WEAR_LOOK_S); self.shared.pupil_share[eye].store((share as f32).to_bits(), Ordering::Relaxed); { // The drift log, while on: what the engine just worked out, read only. Only handed to its thread here (no @@ -1051,6 +1106,9 @@ pub fn spawn( w.last_seen = [None; 2]; w.invalidate(None); } + Ok(Msg::NewWear(why)) => { + w.new_wear_hint(why); + } Ok(Msg::SetLive(on)) => { w.live = on; if !on { @@ -1092,16 +1150,16 @@ mod tests { #[test] fn pupil_share_covers_the_last_two_seconds() { let mut p = PupilSeen::default(); - assert_eq!(p.push(0.0, true), 1.0); + assert_eq!(p.push(0.0, true, PUPIL_SHARE_S), 1.0); for k in 1..=90 { - p.push(k as f64 / 90.0, k % 2 == 0); + p.push(k as f64 / 90.0, k % 2 == 0, PUPIL_SHARE_S); } // 91 frames over 1 s: 46 with the pupil. - assert!((p.push(1.0 + 1.0 / 90.0, false) - 46.0 / 92.0).abs() < 1e-9); + assert!((p.push(1.0 + 1.0 / 90.0, false, PUPIL_SHARE_S) - 46.0 / 92.0).abs() < 1e-9); // 3 s later only the last 2 s count. let mut last = 0.0; for k in 0..=180 { - last = p.push(2.0 + k as f64 / 90.0, k >= 90); + last = p.push(2.0 + k as f64 / 90.0, k >= 90, PUPIL_SHARE_S); } assert!((last - 91.0 / 181.0).abs() < 0.01, "{last}"); } @@ -1321,6 +1379,55 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn a_new_wear_hint_starts_a_new_wear_unless_the_eyes_were_there() { + let dir = std::env::temp_dir().join(format!("eyecam-new-wear-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("calib.json"); + let shared = Arc::new(Shared::default()); + let writer = LiveWriter::create(&dir.join("live")).unwrap(); + let mut w = Worker::new(writer, shared, path.clone(), load_calib(&path), dir.join("preview")); + let wear = live::WearParams { r_px: 50.0, b_n: 0.8, b_w: 1.0, ap_n: 1.6, ap_cl: 0.5, pitch_n: -12.0, lower_px: 240.0 }; + let calibrated = |w: &mut Worker| { + for (p, e) in w.params.iter_mut().zip(&mut w.engines) { + p.wear = Some(wear); + e.seed(&wear); + } + }; + // `n` s of frames at 90 a second on both eyes, the pupil on eye 1 in `share` of them (eye 0 never). + let frames = |w: &mut Worker, t0: f64, n: f64, share: f64| { + for k in 0..(n * 90.0) as usize { + let t = t0 + k as f64 / 90.0; + w.wear_seen[0].push(t, false, NEW_WEAR_LOOK_S); + w.wear_seen[1].push(t, (k % 100) < (share * 100.0) as usize, NEW_WEAR_LOOK_S); + } + }; + // 5 minutes of an empty HMD (no pupil), then the hint: a new wear, the engines and the levels of the wear gone. + calibrated(&mut w); + frames(&mut w, 100.0, 300.0, 0.0); + assert!(w.new_wear_hint("test")); + assert!(w.params.iter().all(|p| p.wear.is_none()) && w.engines.iter().all(|e| !e.baseline_ready())); + assert!(w.wear_seen.iter().all(|s| s.recent.is_empty())); + // Worn all along (one eye has the pupil in 60% of the last 10 s, after minutes without): the same wear. + calibrated(&mut w); + frames(&mut w, 400.0, 300.0, 0.0); + frames(&mut w, 700.0, 10.0, 0.6); + assert!(!w.new_wear_hint("test")); + assert!(w.params.iter().all(|p| p.wear == Some(wear)) && w.engines.iter().all(|e| e.baseline_ready())); + // The pupil in only a little of the last 10 s (being put on): a new wear. + frames(&mut w, 710.0, 10.0, 0.2); + assert!(w.new_wear_hint("test")); + // A calibration running keeps its wear, pupil or not. + calibrated(&mut w); + frames(&mut w, 800.0, 30.0, 0.0); + w.collect = Some(CollectKind::Wear); + assert!(!w.new_wear_hint("test")); + assert!(w.params.iter().all(|p| p.wear == Some(wear))); + drop(w); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn an_old_calib_file_is_converted_once() { let dir = std::env::temp_dir().join(format!("eyecam-calib-convert-{}", std::process::id())); From 6c17600255bae502ce9c8136275006661dacdf29 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sat, 24 Oct 2026 19:03:27 +0900 Subject: [PATCH 31/65] =?UTF-8?q?eyecam:=20=E3=81=8B=E3=81=B6=E3=82=8A?= =?UTF-8?q?=E7=9B=B4=E3=81=97=E3=81=AE=E8=A6=8B=E5=88=86=E3=81=91=E3=81=A7?= =?UTF-8?q?=E3=80=81=E7=9E=B3=E3=81=AE=E5=89=B2=E5=90=88=E3=81=AB=E5=8A=A0?= =?UTF-8?q?=E3=81=88=E3=81=A6=E8=99=B9=E5=BD=A9=E3=81=AE=E5=8D=8A=E5=BE=84?= =?UTF-8?q?=E3=81=8C=E5=B1=A5=E6=AD=B4=E3=81=AB=E8=BF=91=E3=81=84=E3=81=8B?= =?UTF-8?q?=E3=82=82=E8=A6=8B=E3=82=8B=EF=BC=88=E7=A9=BA=E3=81=AE=20HMD=20?= =?UTF-8?q?=E3=81=AE=E5=81=BD=E3=81=AE=E7=9E=B3=E5=AF=BE=E7=AD=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - チェック担当の指摘: 空の HMD でもエンジンは瞳らしいものを採っていた(そのあとの校正で右目の半径 34.5 px、 履歴の中央値 56.0)。偽の瞳が 10 秒の半分以上あれば「同じかぶり」と判断されて直らない - 「同じかぶり」とみなすのは、どちらかの目で、前の 10 秒の映像の 2 割以上で瞳があり、かつエンジンの虹彩の半径が 履歴の中央値の 0.75〜1.33 倍(履歴がないときは割合だけ)のときだけ - 根拠(開発者の Frame の録画 13 本、1370 秒): 半径は中央値の 0.82〜1.18 倍(片目に 2 秒だけ 2.05 倍)。 どの目も条件を満たさない秒は、割合 0.2 で 1 秒、0.5 だと 43 秒(自由に見回すと瞳を見失うため)。だから 0.5 から 0.2 に下げた - 校正を idle の映像が戻る前に頼まれたときは、record() の最初のロックで NewWear を送る(Collect より前なので効く) - ログは目ごとの瞳の割合と虹彩の半径を出す - 更新履歴(英・日): 暗い代わりの絵の結果を本物の空の HMD のように書いていた最後の文を、 本物の校正を最初から覚え直した結果(486 枚すべて)に替え、見分け方の説明を直す。README も - cargo test 128 + 10(Frame、新しい target dir)。空の HMD の本物の映像は見つからなかった (開発者の録画はどれもかぶってから始まっている。近接センサーが 0 のときも目が写っている) Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.ja.md | 2 +- CHANGELOG.md | 2 +- tools/eyecam/README.md | 5 +- tools/eyecam/src/bin/eyecam-rec.rs | 11 +++- tools/eyecam/src/livesvc.rs | 100 +++++++++++++++++++---------- 5 files changed, 79 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 8521bd4..33f227b 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -11,7 +11,7 @@ SteamOS が開いた片目を閉じていると読み続けても、視線が止 - eyecam、ユーザー校正: 細めは、そのかぶりの「開いた〜閉じた」の幅に対する割合で保存されていたのに、使うときは普段の幅に掛けていました。この幅はかぶるたびに 0.6〜1.5 倍違うので、校正した細めの深さが 0.65〜1.5 倍ずれていました。今はユーザー校正で細めの深さそのものも保存し(各目のユーザーの値の `sq_depth`)、使うときもそれを、校正と同じ上限・下限の中で使います。案内に沿った「普通」の段で開いた目が細めと読まれたのは 2.4 % から 0.8 % になり、いちばん細めたときは少し低めに読まれます(0.9 以上が 53 % から 35 %)。新しくユーザー校正をしたあとから効き、前の校正は今まで通り動きます。細めが浅すぎると感じたら、ユーザー校正をやり直してください。前の版に戻してそこで校正を保存すると `sq_depth` が消えるので、更新したあとにユーザー校正をやり直してください - eyecam、度付きインサート: 目のカメラの前にインサートがあると、閉じた目が少し開いていると読まれたり、虹彩が大きすぎると測られて閉じが見えにくくなったりしていました。閉じた目で見つけた下まぶたの線が、開いていたときより虹彩の半径の 0.9 倍以上下にあるときや、探す範囲の下端に張り付いているときは使わず、最後に瞳が見えていたときの線を使います(下まぶたはまばたきでほとんど動かないため)。瞳が欠けずに見えているのに上まぶたの線が瞳の中心より下にあるときも、その線は使いません。そういうかぶりの校正のサンプルで測り直すと、左目の「開いた〜閉じた」の幅は 0.30 から 1.35 になりました(右目は 1.31)。かぶりの虹彩の半径が開いた瞳の 4 倍を超えたら、虹彩の縁をいちばん強い明るさの変わり目ではなく、虹彩から出て最初の立ち上がりで測り、半径と開いた目の iris box の明るさを覚え直します。半径を 61 / 54 px から 79 / 75 px に引っぱる帯を足した録画 26 本で流し直すと、半径は 72 / 67 px、閉じの取りこぼしは 1 分あたり 1.27 回から 1.08 回、0.2 秒未満のまばたきは 80 % から 83 % 取れるようになり、そのかわり開いた目が半目と読まれたのは 5.3 % から 6.4 % になりました。元の録画では半径の切り替えは起きず、案内に沿った閉じが取れたのは 97.3 % から 98.2 % で、ほかはほぼ同じでした(開いた目が半目と読まれたのは 5.3 % から 5.4 %)。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください - eyecam、度付きインサートと見開き: 白目が明るく照らされていてインサートの反射もあると、上まぶたの線をその明るいところの上の縁(瞳の中心より下か、すぐ上)で取ってしまうことが多く、かぶりの校正では見開きの高さを長い上まつ毛の先で取っていました。そのため見開きの幅が普段の 3〜4 倍になって見開きに届かず、開いた目は細めに読まれていました。瞳が欠けずに見えているのに線が瞳の上半分より下にあるときは、瞳の上でもう一度探し、見つかった線が元の線と同じくらい以上はっきりしていればそちらを使います。見開きの高さを上の範囲から取るときに、その範囲で普段の段にも線があれば、その線が上がった分を見開きの幅にします。虹彩の半径の 1.1 倍を超える見開きの幅は見開きが取れなかったものとして扱い(いつもの幅を使います)、すでに保存されている 0.9 倍を超える幅は履歴の中央値に入れません。そういう人の録画で流し直すと、見開きの幅は 1.30 / 1.15 から 0.52 / 0.51 になりました。同じ録画で作ったユーザー校正を当てた(甘めの)確認では、右目は見開きの間の約 90 % で見開きと読まれ(前は 0 %)、左目は 19〜76 %(前は 0〜35 %)、普段の段の細めは 0.16〜0.63 から 0〜0.07 になりました。左目の見開いた線は虹彩の上端に乗ることが多く、見開きはまだ少し低めに出ることがあります。開発者の録画では校正は同じ値で、19 本のうち 1 本でサンプルの 0.16 % が変わりました。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください。 -- eyecam: HMD をかぶっていないときに目の映像をつかむと(再起動のあとなど)、目のカメラは空の HMD の絵から瞳を探す範囲や明るさを覚えてしまい、かぶったときの映像の途切れが 10 秒より短いとそれが残って、そのあとのカメラの校正で瞳が 486 枚中 5〜162 枚しか見つかりませんでした。今は、途切れた映像が戻ったとき(途切れの長さによらず)と、近接センサーが 10 秒以上「かぶっていない」と言ったあとに「かぶった」に変わったときも、かぶり直したとみなして覚え直します。ただし、その前の 10 秒の映像でどちらかの目の瞳が半分以上見えていたとき(センサーはかぶっていても 1〜2 秒低く読むことがあります)と、校正中は覚え直しません。空の HMD の映像を 5 分流したあとにその校正を流し直すと、右目の瞳が見つかったのは 486 枚中 97 枚から 486 枚になりました +- eyecam: HMD をかぶっていないときに目の映像をつかむと(再起動のあとなど)、目のカメラは空の HMD の絵から瞳を探す範囲や明るさを覚えてしまい、かぶったときの映像の途切れが 10 秒より短いとそれが残って、そのあとのカメラの校正で瞳が 486 枚中 5〜162 枚しか見つかりませんでした。今は、途切れた映像が戻ったとき(途切れの長さによらず)と、近接センサーが 10 秒以上「かぶっていない」と言ったあとに「かぶった」に変わったときも、かぶり直したとみなして覚え直します。ただし、まだかぶっているように見える目があるとき(その前の 10 秒の映像の 5 分の 1 以上で瞳が見えていて、虹彩の大きさがいつもに近い。センサーはかぶっていても 1〜2 秒低く読むことがあります)と、校正中は覚え直しません。最初から覚え直せば、その校正の 486 枚すべてで瞳が見つかりました - パネル: 目のカメラの校正中は、視線の合わせ直しと目合わせが始まらないようにしました。ユーザー校正の途中にほかのアプリから合わせ直しを頼まれると(frame-aux-shortcuts の 2 回タップなど)、点が出て、校正のために上を見ているときの視線の正面を保存していました。`--action recenter`・`fit`・`relearn` は 11 で終わり(1 行に「calibrating」と出ます)、その間は[目を合わせる]と[真ん中だけ合わせ直す]がグレーになり、被ったときの自動の合わせ直しは校正が終わるまで待ちます - ログ: 「Eye data has been low」の行で、取りこぼしがないときに「-0 published samples were missed」と出ていたのを、0 と出すようにしました。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 148ff84..e73536b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The gaze no longer stays frozen when SteamOS keeps reading one open eye as close - eyecam, user calibration: the squint was saved as a fraction of that wear's open-to-closed gap but used against the usual gap, which differs by 0.6 to 1.5 times from wear to wear, so the calibrated squint came out 0.65 to 1.5 times as deep. The user calibration now also saves the squint's depth (`sq_depth` in each eye's user values), and live uses it, kept within the same limits as the calibration's. Open eyes read as a squint went from 2.4 % to 0.8 % in the guided normal step; a full squint reads a little lower (0.9 or more 35 % of the time instead of 53 %). This takes effect after a new user calibration; older calibrations work as before. If your squint now reads too shallow, run the user calibration again. Going back to an older version and saving a calibration there drops `sq_depth`, so run the user calibration again after updating. - eyecam, prescription inserts: with an insert in front of the eye cameras, a closed eye could read partly open, and an iris fit far too large made closes harder to see. A lower lid line found on a closed eye 0.9 iris radii or more below where it was with the eye open, or against the bottom of its search band, is no longer taken: the last one seen with the pupil is used instead (the lower lid hardly moves in a blink), and an upper lid line below the centre of a pupil seen whole is dropped. Re-fitting one such wear calibration's samples, the left eye's open-to-closed gap goes from 0.30 to 1.35 (its right eye: 1.31). When a wear's iris radius comes out over 4 times the open pupil's, the iris edge is taken at the first rise out of the iris instead of the strongest edge, and the radius and the open eye's iris-box level are learnt again. Replayed on 26 recordings with a band added that pulls the radius from 61 / 54 px to 79 / 75 px: 72 / 67 px, closes missed 1.08 instead of 1.27 a minute, blinks under 0.2 s caught 83 % instead of 80 %, but open eyes read half-shut 6.4 % of the time instead of 5.3 %. On the original recordings the radius switch never happens; guided closes were caught 98.2 % of the time instead of 97.3 %, and everything else stayed about the same (open eyes read half-shut 5.4 % of the time instead of 5.3 %). If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. - eyecam, prescription inserts and widening: with a brightly lit white of the eye and glints from an insert, the upper lid line was often taken at the bright area's upper edge, under or just above the pupil's centre, and the wear calibration's widen level at the tips of long upper lashes, so the widen step came out 3 to 4 times the usual size and widening could never be reached, while open eyes read as a squint. Now, when the pupil is seen whole and the line lies below the upper half of the pupil, the lid is searched for again above the pupil, and that line is taken if it is at least as clear. When the widen level comes from the higher search area and that area also has a line in the normal step, the widen step is how far that line rose. A widen step above 1.1 iris radii is taken as a widen not caught (the usual step is used), and steps above 0.9 already saved are left out of the history's median. Replaying one such wearer's recording, the widen step went from 1.30 / 1.15 to 0.52 / 0.51, and with a user calibration made from that same recording (so a favourable test) the right eye read as widened in about 90 % of its widen frames instead of none (the left eye 19 to 76 % instead of 0 to 35 %), and the squint in the normal step went from 0.16-0.63 to 0-0.07. The left eye's widened line often sits at the top of the iris, so its widening may still read a little low. On the developer's recordings, the calibrations came out the same, and 0.16 % of the samples in one of 19 recordings changed. If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. -- eyecam: when the eye cameras locked on while the headset was off (after a restart, say), they learnt the empty headset's picture (where to look for the pupil, its brightness), and kept it once the headset was put on if the video paused for less than 10 s; the next camera calibrations then found the pupil in only 5 to 162 of 486 frames. The headset now also counts as put on again when the eye video comes back after a pause, however short, and when the proximity sensor says worn after 10 s of not worn, unless either eye's pupil was seen in at least half of the last 10 s of video (the sensor can read low for a second or two while worn), and never during a calibration. Replaying that calibration after 5 minutes of an empty headset: the right eye's pupil found in 97 of 486 frames before, 486 now. +- eyecam: when the eye cameras locked on while the headset was off (after a restart, say), they learnt the empty headset's picture (where to look for the pupil, its brightness), and kept it once the headset was put on if the video paused for less than 10 s; the next camera calibrations then found the pupil in only 5 to 162 of 486 frames. The headset now also counts as put on again when the eye video comes back after a pause, however short, and when the proximity sensor says worn after 10 s of not worn, unless an eye still looks worn (its pupil seen in at least a fifth of the last 10 s of video, at an iris size close to your usual one; the sensor can read low for a second or two while worn), and never during a calibration. A fresh start found the pupil in all 486 frames of that calibration. - Panel: while the eye cameras calibrate, the gaze re-center and the eye fit no longer start. A re-center asked for by another app (a double tap in frame-aux-shortcuts, say) during a user calibration showed its dot and saved the gaze center measured while you were looking up for the calibration. `--action recenter`, `fit` and `relearn` now exit with 11 and "calibrating" in their line, "Fit my eyes" and "Re-center only" are grey meanwhile, and the automatic re-center after putting the headset on waits for the calibration to end. - Logs: the "Eye data has been low" line said "-0 published samples were missed" when none were; it now says 0. diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index e5b1f09..652c4b5 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -661,8 +661,9 @@ python3 ~/eyecam-src/tools/drift_report.py ~/eyecam/drift/drift_….csv # 5 入れ替えて、印を付けて保存し直す - 映像が 10 秒以上止まったら(HMD を外したら)新しいかぶりとみなして、かぶりの校正と学習した値(レンズの端、虹彩の半径、開いた目の明るさ、 基準線)を捨てて、もう一度覚える。止まった映像が戻ったとき(止まっていた長さによらない)と、近接センサーが 10 秒以上「かぶっていない」と - 言ったあと「かぶった」に変わったときも同じ。ただし、その前の 10 秒の映像でどちらかの目の瞳が半分以上見えていたら(かぶったままだった。 - センサーはかぶっていても上・下を見たり見開いたりすると 1〜2 秒 0〜15 に落ちる)と、校正中はそのまま。HMD を外したまま起動すると、 + 言ったあと「かぶった」に変わったときも同じ。ただし、その前の 10 秒の映像で瞳が 2 割以上見えていて、エンジンの虹彩の半径が履歴の中央値の 0.75〜1.33 倍 + (履歴がなければ見ない)の目があったら(かぶったままだった。センサーはかぶっていても上・下を見たり見開いたりすると 1〜2 秒 0〜15 に + 落ちる。空の HMD でも瞳らしいものが見つかることがあり、そのあとの校正では右目の半径が 34.5 px〔中央値 56.0〕だった)と、校正中はそのまま。HMD を外したまま起動すると、 外した HMD の絵から覚えた値(探す範囲が x 117 から、コントラスト 33)がかぶったあとも残り、かぶりの校正で瞳が 486 枚中 5〜162 枚しか 見つからなかったため(2026-10-24) - 校正を始めるときは、瞳を探す目安(前の瞳の位置)を消してから始める(かぶりの学習値はそのまま) diff --git a/tools/eyecam/src/bin/eyecam-rec.rs b/tools/eyecam/src/bin/eyecam-rec.rs index c641c80..33d0157 100644 --- a/tools/eyecam/src/bin/eyecam-rec.rs +++ b/tools/eyecam/src/bin/eyecam-rec.rs @@ -376,7 +376,7 @@ fn run() -> Result<()> { let mut buffers = Buffers::receive(args.wait_grab, None)?; let mut prox = Proximity::find(); let protocol = args.cues.as_ref().map_or(String::new(), |p| p.display().to_string()); - let ended = record(&mut buffers, &mut None, &args, steps, &protocol, &mut prox, None, None, Mode::Record)?; + let ended = record(&mut buffers, &mut None, &args, steps, &protocol, &mut prox, None, None, Mode::Record, false)?; match (ended.dir, ended.abort) { (None, Some(abort)) => Err(abort.message().into()), _ => Ok(()), @@ -1082,7 +1082,8 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m cached = Some(p.ring); } let feed = if live.on { Some(&mut *live) } else { None }; - let ended = record(b, &mut cached, args, Some(steps), &name, prox, Some(d), feed, mode).unwrap_or_else(|e| { + let back = std::mem::take(&mut came_back); + let ended = record(b, &mut cached, args, Some(steps), &name, prox, Some(d), feed, mode, back).unwrap_or_else(|e| { eprintln!("eyecam-rec: {e}"); Ended { dir: None, abort: None, failure: Some(format!("失敗した: {e}")), aborted: true, calib: None } }); @@ -2179,6 +2180,7 @@ fn record( ctl: Option<&Daemon>, mut live: Option<&mut Live>, mode: Mode, + came_back: bool, ) -> Result { let recording = mode == Mode::Record; let busy_state = if recording { "recording" } else { "calibrating" }; @@ -2213,6 +2215,11 @@ fn record( Err(abort) => return Ok(Ended::early(abort)), }, }; + // Idle's eye frames had stopped and come back only now: before the calibration starts collecting (it keeps its + // wear), as idle would have said (livesvc::NEW_WEAR_OFF_S). + if came_back && let Some(l) = live.as_deref() { + l.send(Msg::NewWear("the eye frames came back")); + } // Session files. Calibrations are saved the same way (calib_*) but, unless `dev` is on in settings.json, without // any eye images (no eye_*.raw, headers.bin, lock_dump.bin): only the small text files, among them the diff --git a/tools/eyecam/src/livesvc.rs b/tools/eyecam/src/livesvc.rs index 0300563..d3ae5c8 100644 --- a/tools/eyecam/src/livesvc.rs +++ b/tools/eyecam/src/livesvc.rs @@ -15,17 +15,24 @@ use std::time::{Duration, Instant}; const NEW_WEAR_GAP: f64 = 10.0; /// eyecam-rec also says when a new wear may have begun (`Msg::NewWear`): the eye frames came back after they stopped, /// however short the gap, or the proximity sensor says worn after NEW_WEAR_OFF_S of not worn. It is taken as one -/// (as after NEW_WEAR_GAP) unless either eye had the pupil in at least NEW_WEAR_KEEP_SHARE of its frames over the last -/// NEW_WEAR_LOOK_S before it: the eyes were there, so the HMD was worn all along (the sensor reads 0 to 15 for a -/// second or two while worn, looking up, down or widening: the developer's calibrations of 2026-10-24), or the frames -/// only paused. A calibration running keeps its wear. 2026-10-24: after a restart the frames locked with the HMD off -/// (proximity 15.2) and the engines learnt the lens edge, levels and iris radius from 5 minutes of an empty HMD +/// (as after NEW_WEAR_GAP) unless an eye looks worn: the pupil in at least NEW_WEAR_KEEP_SHARE of its frames over the +/// last NEW_WEAR_LOOK_S, and the engine's iris radius within NEW_WEAR_R of the wear history's median (when there is a +/// history). Then the HMD was worn all along (the sensor reads 0 to 15 for a second or two while worn, looking up, +/// down or widening: the developer's calibrations of 2026-10-24), or the frames only paused. A calibration running +/// keeps its wear. The radius is there because an empty HMD can give pupils of its own (the engines learn the radius +/// only from pupils): after the empty HMD below, the first calibration ran at 34.5 px on the right eye against a +/// history median of 56.0 (62.8 on the left, median 60.5; how many pupils either eye had on the empty HMD is not +/// known). The developer's 13 recordings on the Frame (1370 s, free use and guided steps): the radius stays within +/// 0.82 to 1.18 of the median but for one eye's 2 s spike to 2.05, and no eye passed both checks in 1 s at a share of +/// 0.2 (43 s at 0.5, as free use loses the pupil looking far aside). 2026-10-24: after a restart the frames locked +/// with the HMD off (proximity 15.2) and the engines learnt the lens edge, levels and iris radius from 5 minutes of an empty HMD /// (search window from x 117 instead of 180, contrast 33 instead of 85, the closed-lid check refusing the pupil); /// the frames stopped for 5 s when it was put on, under NEW_WEAR_GAP, and three wear calibrations found the pupil /// in 88 / 5, 130 / 13 and 162 / 85 of 486 normal frames (486 / 486 from a fresh engine). pub const NEW_WEAR_OFF_S: f64 = 10.0; const NEW_WEAR_LOOK_S: f64 = 10.0; -const NEW_WEAR_KEEP_SHARE: f64 = 0.5; +const NEW_WEAR_KEEP_SHARE: f64 = 0.2; +const NEW_WEAR_R: (f64, f64) = (0.75, 1.33); /// An eye's published values are marked invalid when its frames stop for this long. const STALE_AFTER: f64 = 0.25; /// status.json's pupil_l / pupil_r: the share of frames with the pupil over this many seconds; cam_fps: the frames @@ -435,8 +442,9 @@ struct Worker { flipped: Vec, pupils: [PupilTrack; 2], pupil_seen: [PupilSeen; 2], - /// The same over NEW_WEAR_LOOK_S, for `new_wear_hint`. + /// The same over NEW_WEAR_LOOK_S, and each engine's last iris radius (px), for `new_wear_hint`. wear_seen: [PupilSeen; 2], + wear_r: [f64; 2], pupil_offset: PupilOffset, pub pupil_conflicts: u64, pub pupil_pairs: u64, @@ -481,6 +489,7 @@ impl Worker { pupils: [PupilTrack::default(), PupilTrack::default()], pupil_seen: [PupilSeen::default(), PupilSeen::default()], wear_seen: [PupilSeen::default(), PupilSeen::default()], + wear_r: [f64::NAN; 2], pupil_offset: PupilOffset::default(), pupil_conflicts: 0, pupil_pairs: 0, @@ -598,32 +607,32 @@ impl Worker { self.pupils = [PupilTrack::default(), PupilTrack::default()]; self.pupil_seen = [PupilSeen::default(), PupilSeen::default()]; self.wear_seen = [PupilSeen::default(), PupilSeen::default()]; + self.wear_r = [f64::NAN; 2]; self.pupil_offset = PupilOffset::default(); self.last_t = [f64::NEG_INFINITY; 2]; } /// eyecam-rec's hint that a new wear may have begun (NEW_WEAR_OFF_S): a new wear unless a calibration is running - /// or either eye had the pupil in NEW_WEAR_KEEP_SHARE of its last NEW_WEAR_LOOK_S of frames. Returns whether it - /// was taken as one. + /// or an eye looks worn (its pupil share and iris radius). Returns whether it was taken as one. fn new_wear_hint(&mut self, why: &str) -> bool { - let share = self.wear_seen.iter().map(|s| s.share()).fold(0.0, f64::max); - let kept = if self.collect.is_some() { - Some("a calibration is running".to_string()) - } else if share >= NEW_WEAR_KEEP_SHARE { - Some(format!("pupil in {:.0}% of the last {NEW_WEAR_LOOK_S} s of frames", 100.0 * share)) - } else { - None + let looks = |e: usize| { + let share = self.wear_seen[e].share(); + let r = self.wear_r[e]; + let r_ok = self.params[e].hist.is_none_or(|h| r >= NEW_WEAR_R.0 * h.r_px && r <= NEW_WEAR_R.1 * h.r_px); + (share >= NEW_WEAR_KEEP_SHARE && r_ok, format!("{}: pupil {:.0}%, iris radius {:.1} px", ["L", "R"][e], 100.0 * share, r)) }; - match kept { - Some(reason) => { - eprintln!("live: {why}; same wear ({reason})"); - false - } - None => { - eprintln!("live: {why}; a new wear (pupil in {:.0}% of the last {NEW_WEAR_LOOK_S} s of frames): learning it again", 100.0 * share); - self.new_wear(); - true - } + let (eyes, seen): (Vec, Vec) = [looks(0), looks(1)].into_iter().unzip(); + let seen = seen.join(", "); + if self.collect.is_some() { + eprintln!("live: {why}; same wear (a calibration is running)"); + false + } else if eyes.contains(&true) { + eprintln!("live: {why}; same wear ({seen} over the last {NEW_WEAR_LOOK_S} s)"); + false + } else { + eprintln!("live: {why}; a new wear ({seen} over the last {NEW_WEAR_LOOK_S} s): learning it again"); + self.new_wear(); + true } } @@ -671,6 +680,9 @@ impl Worker { self.check_pupils(eye, t, &mut o); let share = self.pupil_seen[eye].push(t, o.f.ok_pupil, PUPIL_SHARE_S); self.wear_seen[eye].push(t, o.f.ok_pupil, NEW_WEAR_LOOK_S); + if o.r.is_finite() { + self.wear_r[eye] = o.r; + } self.shared.pupil_share[eye].store((share as f32).to_bits(), Ordering::Relaxed); { // The drift log, while on: what the engine just worked out, read only. Only handed to its thread here (no @@ -1395,32 +1407,50 @@ mod tests { e.seed(&wear); } }; - // `n` s of frames at 90 a second on both eyes, the pupil on eye 1 in `share` of them (eye 0 never). - let frames = |w: &mut Worker, t0: f64, n: f64, share: f64| { + // `n` s of frames at 90 a second on both eyes, the pupil on eye 1 in `share` of them (eye 0 never), eye 1's + // iris radius `r`. + let frames = |w: &mut Worker, t0: f64, n: f64, share: f64, r: f64| { for k in 0..(n * 90.0) as usize { let t = t0 + k as f64 / 90.0; w.wear_seen[0].push(t, false, NEW_WEAR_LOOK_S); w.wear_seen[1].push(t, (k % 100) < (share * 100.0) as usize, NEW_WEAR_LOOK_S); } + w.wear_r[1] = r; }; + // No history: the share alone. // 5 minutes of an empty HMD (no pupil), then the hint: a new wear, the engines and the levels of the wear gone. calibrated(&mut w); - frames(&mut w, 100.0, 300.0, 0.0); + frames(&mut w, 100.0, 300.0, 0.0, 55.0); assert!(w.new_wear_hint("test")); assert!(w.params.iter().all(|p| p.wear.is_none()) && w.engines.iter().all(|e| !e.baseline_ready())); - assert!(w.wear_seen.iter().all(|s| s.recent.is_empty())); - // Worn all along (one eye has the pupil in 60% of the last 10 s, after minutes without): the same wear. + assert!(w.wear_seen.iter().all(|s| s.recent.is_empty()) && w.wear_r.iter().all(|r| r.is_nan())); + // Worn all along (one eye has the pupil in 30% of the last 10 s, after minutes without): the same wear. calibrated(&mut w); - frames(&mut w, 400.0, 300.0, 0.0); - frames(&mut w, 700.0, 10.0, 0.6); + frames(&mut w, 400.0, 300.0, 0.0, 55.0); + frames(&mut w, 700.0, 10.0, 0.3, 55.0); assert!(!w.new_wear_hint("test")); assert!(w.params.iter().all(|p| p.wear == Some(wear)) && w.engines.iter().all(|e| e.baseline_ready())); // The pupil in only a little of the last 10 s (being put on): a new wear. - frames(&mut w, 710.0, 10.0, 0.2); + frames(&mut w, 710.0, 10.0, 0.1, 55.0); + assert!(w.new_wear_hint("test")); + // With a history (R 56 px): pupils with an iris radius far from it are not this wearer's (an empty HMD's own, + // 34.5 px on 2026-10-24), and one within it is. + let hist = live::History { step: 0.3, gap: 0.9, r_px: 56.0 }; + for p in &mut w.params { + p.hist = Some(hist); + } + calibrated(&mut w); + frames(&mut w, 800.0, 10.0, 0.9, 34.5); assert!(w.new_wear_hint("test")); + calibrated(&mut w); + frames(&mut w, 900.0, 10.0, 0.9, 80.0); + assert!(w.new_wear_hint("test")); + calibrated(&mut w); + frames(&mut w, 1000.0, 10.0, 0.9, 50.0); + assert!(!w.new_wear_hint("test")); // A calibration running keeps its wear, pupil or not. calibrated(&mut w); - frames(&mut w, 800.0, 30.0, 0.0); + frames(&mut w, 1100.0, 30.0, 0.0, 34.5); w.collect = Some(CollectKind::Wear); assert!(!w.new_wear_hint("test")); assert!(w.params.iter().all(|p| p.wear == Some(wear))); From 33d8d67e5bed05e707e4914fb12cfa9dcdd25319 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 12:03:09 +0900 Subject: [PATCH 32/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20eyecam-rec?= =?UTF-8?q?=20=E3=81=AE=20status.json=20=E3=81=AE=20last=5Fsession=5Fabort?= =?UTF-8?q?ed=20=E3=82=92=E8=AA=AD=E3=82=80=EF=BC=88=E6=9C=80=E5=BE=8C?= =?UTF-8?q?=E3=81=AE=E9=8C=B2=E7=94=BB=E3=81=8C=E9=80=94=E4=B8=AD=E3=81=A7?= =?UTF-8?q?=E7=B5=82=E3=82=8F=E3=81=A3=E3=81=9F=E3=81=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- panel/src/eyecam.cpp | 2 ++ panel/src/eyecam.h | 2 ++ 2 files changed, 4 insertions(+) diff --git a/panel/src/eyecam.cpp b/panel/src/eyecam.cpp index 9ff58e0..8f26d05 100644 --- a/panel/src/eyecam.cpp +++ b/panel/src/eyecam.cpp @@ -137,6 +137,8 @@ Status parseStatus(const std::string& text, double mtime) { status.totalS = readNumber(root, "total_s", kNaN); status.sessionDir = readText(root, "session_dir"); status.protocol = readText(root, "protocol"); + const JsonValue* aborted = root.get("last_session_aborted"); + status.lastSessionAborted = aborted != nullptr && aborted->isBool() && aborted->boolean; // Bits beyond the two known ones are kept, but only the two are used status.calibState = std::max(0, readInt(root, "calib_state", 0)); const JsonValue* recalib = root.get("recalib_suggested"); diff --git a/panel/src/eyecam.h b/panel/src/eyecam.h index 403d363..416fbdf 100644 --- a/panel/src/eyecam.h +++ b/panel/src/eyecam.h @@ -215,6 +215,8 @@ struct Status { double totalS = 0.0; std::string sessionDir; std::string protocol; + bool lastSessionAborted = false; ///< "last_session_aborted": the last recording ended early (its meta.txt says + ///< aborted=1; false when missing) int calibState = 0; ///< kCalibWearBit | kCalibUserBit | kCalibAutoBit (0 when missing) bool recalibSuggested = false; ///< drifted since the calibration: "calib wear" again bool live = false; ///< the eyelids are read from the cameras live From d0a8ba2269cd079ab33135f4c513e4ecc1cc92a6 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 12:03:09 +0900 Subject: [PATCH 33/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E3=80=8CA?= =?UTF-8?q?I=20=E3=81=AE=E5=AD=A6=E7=BF=92=E3=81=AB=E5=8D=94=E5=8A=9B?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=80=8D=E3=82=92=E8=B6=B3=E3=81=99=EF=BC=88?= =?UTF-8?q?=E8=A9=B3=E7=B4=B0=20=E2=80=BA=20=E5=8B=95=E4=BD=9C=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E3=81=8B=E3=82=89=E3=80=81=E5=8D=8A=E7=9B=AE=E3=81=AE?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E7=9B=B4=E3=81=97=EF=BC=88AI=EF=BC=89?= =?UTF-8?q?=E3=81=AE=E5=AD=A6=E7=BF=92=E7=94=A8=E3=81=AE=E9=8C=B2=E7=94=BB?= =?UTF-8?q?=E3=82=92=E6=9C=80=E5=BE=8C=E3=81=BE=E3=81=A7=E6=A1=88=E5=86=85?= =?UTF-8?q?=E3=81=99=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同意の画面(録るもの・使いみち・消してほしいとき・空き容量)のあと、光なしの撮り方 3 回とふだん使い 3 分(任意でもう 1 回)を順に録る。 2 回目からは HMD のかぶり直しを eyecam-rec の状態(目の映像・近接センサー・スリープ)から見分け、20 秒後からは[かぶり直した]でも進める。 終わった録画は ~/eyecam/contrib_日時/ に移し、info.txt に進み具合を残して起動し直しても続きからできるようにする。 途中で止まったものは aborted/ に入れて録り直せる。最後に tar(zstd があれば .tar.zst)にまとめるのと、確認つきで消すのを別スレッドで行う。 --fake-contrib で各画面を描ける。contrib-test と panel-test にテストを足す。 Co-Authored-By: Claude Opus 5.5 --- panel/CMakeLists.txt | 14 + panel/src/contrib.cpp | 1128 ++++++++++++++++++++++++++++++++++ panel/src/contrib.h | 689 +++++++++++++++++++++ panel/src/i18n.cpp | 165 +++++ panel/src/i18n.h | 70 +++ panel/src/main.cpp | 224 ++++++- panel/src/model.h | 2 + panel/src/panel.cpp | 489 ++++++++++++++- panel/src/panel.h | 80 ++- panel/tests/contrib_test.cpp | 1033 +++++++++++++++++++++++++++++++ panel/tests/panel_test.cpp | 149 ++++- 11 files changed, 4030 insertions(+), 13 deletions(-) create mode 100644 panel/src/contrib.cpp create mode 100644 panel/src/contrib.h create mode 100644 panel/tests/contrib_test.cpp diff --git a/panel/CMakeLists.txt b/panel/CMakeLists.txt index ce5bd4c..c461e36 100644 --- a/panel/CMakeLists.txt +++ b/panel/CMakeLists.txt @@ -43,6 +43,7 @@ add_executable(frameeyeosc-panel src/changelog.cpp src/command.cpp src/config.cpp + src/contrib.cpp src/diag.cpp src/draw.cpp src/eyecam.cpp @@ -155,6 +156,7 @@ add_executable(panel-test tests/panel_test.cpp src/changelog.cpp src/command.cpp src/config.cpp + src/contrib.cpp src/diag.cpp src/draw.cpp src/eyecam.cpp @@ -211,6 +213,18 @@ target_compile_options(eyecam-test PRIVATE -Wall -Wextra) target_link_libraries(eyecam-test PRIVATE Threads::Threads) add_test(NAME eyecam COMMAND eyecam-test) +# "Help train the AI" (contrib.{h,cpp}: the steps and their sizes against eyecam's protocol files, info.txt, following a +# step's recording and the re-wear in eyecam-rec's status, the screens, the tar headers, and on folders in $TMPDIR +# making, moving into, packing (unpacked again with tar and zstd) and deleting a contribution, and a whole session): +# panel/build/contrib-test +add_executable(contrib-test tests/contrib_test.cpp src/contrib.cpp src/eyecam.cpp src/i18n.cpp src/json.cpp + src/setup_tools.cpp src/recorder.cpp) +target_include_directories(contrib-test PRIVATE src) +target_compile_definitions(contrib-test PRIVATE EYECAM_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/../tools/eyecam") +target_compile_options(contrib-test PRIVATE -Wall -Wextra) +target_link_libraries(contrib-test PRIVATE Threads::Threads) +add_test(NAME contrib COMMAND contrib-test) + # The diagnostics page's texts and code (diag.{h,cpp}: the cards, the diagnostic code, SteamOS's version, SHA-256), # without cairo: panel/build/diag-test add_executable(diag-test tests/diag_test.cpp src/diag.cpp src/eyecam.cpp src/i18n.cpp src/json.cpp src/status.cpp diff --git a/panel/src/contrib.cpp b/panel/src/contrib.cpp new file mode 100644 index 0000000..6d60612 --- /dev/null +++ b/panel/src/contrib.cpp @@ -0,0 +1,1128 @@ +// "Help train the AI": the contribution's progress (info.txt), which step comes next and whether it fits, following its +// recordings and the re-wear between them in eyecam-rec's status, moving the recordings into its folder, and packing +// or deleting it on a thread of its own. +#include "contrib.h" + +#include "file_io.h" +#include "recorder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace contrib { + +namespace { + +/** Bytes read from a recording's file at a time while packing. */ +constexpr size_t kChunk = 1 << 20; +/** The packing thread's and zstd's niceness (the panel and VR come first). */ +constexpr int kNice = 10; + +/** + * Whether text from a position is "YYYY-MM-DD_HH-MM-SS", and nothing after it. + * @param text the text + * @param at where it starts + * @return true if it is + */ +bool stampAt(const std::string& text, size_t at) { + static const char* const shape = "dddd-dd-dd_dd-dd-dd"; + if (text.size() != at + 19) return false; + for (size_t i = 0; i < 19; ++i) { + const char c = text[at + i]; + if (shape[i] == 'd' ? !std::isdigit(static_cast(c)) : c != shape[i]) return false; + } + return true; +} + +/** + * The names in a folder. + * @param dir the folder + * @return them ("." and ".." left out), sorted + */ +std::vector names(const std::string& dir) { + std::vector found; + DIR* d = ::opendir(dir.c_str()); + if (d == nullptr) return found; + while (const dirent* e = ::readdir(d)) { + const std::string name = e->d_name; + if (name != "." && name != "..") found.push_back(name); + } + ::closedir(d); + std::sort(found.begin(), found.end()); + return found; +} + +/** + * Whether a path is a folder itself (not a link to one). + * @param path the path + * @return true if it is + */ +bool isRealDir(const std::string& path) { + struct stat st {}; + return ::lstat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode); +} + +/** + * The last part of a path. + * @param path the path (without a trailing "/") + * @return its name + */ +std::string baseName(const std::string& path) { + const size_t slash = path.find_last_of('/'); + return slash == std::string::npos ? path : path.substr(slash + 1); +} + +/** + * The folder a path is in. + * @param path the path (without a trailing "/") + * @return the folder ("" for a bare name) + */ +std::string parentOf(const std::string& path) { + const size_t slash = path.find_last_of('/'); + return slash == std::string::npos ? std::string() : path.substr(0, slash); +} + +/** + * A whole number from info.txt. + * @param text the value + * @param fallback for anything else + * @return the number + */ +long long readInteger(const std::string& text, long long fallback) { + if (text.empty()) return fallback; + char* end = nullptr; + errno = 0; + const long long value = std::strtoll(text.c_str(), &end, 10); + return errno == 0 && end != nullptr && *end == '\0' ? value : fallback; +} + +/** + * Write all of a buffer, to a file or (without SIGPIPE) to a socket. + * @param fd where + * @param data the bytes + * @param size how many + * @param socket fd is a socket + * @return false on an error (errno says it) + */ +bool writeAll(int fd, const char* data, size_t size, bool socket) { + size_t done = 0; + while (done < size) { + const ssize_t n = socket ? ::send(fd, data + done, size - done, MSG_NOSIGNAL) + : ::write(fd, data + done, size - done); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return false; + done += static_cast(n); + } + return true; +} + +/** + * Put an octal number into a tar header field: digits, then a NUL. + * @param field the field + * @param width its width + * @param value the number (fits in width - 1 octal digits) + */ +void putOctal(char* field, size_t width, unsigned long long value) { + field[width - 1] = '\0'; + for (size_t i = width - 1; i > 0; --i) { + field[i - 1] = static_cast('0' + (value & 7)); + value >>= 3; + } +} + +} // namespace + +const char* protocolOf(int step) { + return isFree(step) ? kFreeProtocol : kGuidedProtocol; +} + +bool isFree(int step) { + return step >= 3; +} + +bool needsRewear(int step) { + return step > 0; +} + +long long estimateBytes(int step, double fps) { + const double rate = std::isfinite(fps) && fps > 0 ? fps : kDefaultFps; + const double seconds = (isFree(step) ? kFreeSec : kGuidedSec) + kTailSec; + return static_cast(seconds * 2 * rate * kFrameBytes); +} + +int nextStep(const Info& info, bool extra) { + for (int step = 0; step < kRequiredSteps; ++step) { + if (info.steps[step].rec.empty()) return step; + } + return extra && info.steps[kRequiredSteps].rec.empty() ? kRequiredSteps : kStepCount; +} + +int requiredDone(const Info& info) { + int done = 0; + for (int step = 0; step < kRequiredSteps; ++step) done += info.steps[step].rec.empty() ? 0 : 1; + return done; +} + +long long recordedBytes(const Info& info) { + long long total = 0; + for (const Step& step : info.steps) total += step.rec.empty() ? 0 : step.bytes; + return total; +} + +long long neededBytes(const Info& info, double fps) { + long long total = kMarginBytes; + for (int step = 0; step < kRequiredSteps; ++step) { + if (info.steps[step].rec.empty()) total += estimateBytes(step, fps); + } + return total; +} + +bool fits(int step, long long freeBytes, double fps) { + return freeBytes < 0 || freeBytes >= estimateBytes(step, fps) + kMarginBytes; +} + +long long packNeedBytes(const Info& info, bool compressed) { + const double share = compressed ? kPackShare : 1.0; + return static_cast(recordedBytes(info) * share) + kMarginBytes; +} + +std::string formatInfo(const Info& info) { + std::ostringstream out; + out << "# frameeyeosc: eye-camera recordings for the half-shut check (AI), made with the panel's \"Help train the " + "AI\".\n" + "# Each step's recording is its folder here, as eyecam-rec writes it (cues.csv, valve.csv, frames.csv, " + "meta.txt,\n" + "# eye_L.raw, eye_R.raw). Steps 1-3 follow the protocol without light, 4 (and the optional 5) are free " + "use, each in\n" + "# a wear of its own (wear: first / detected = seen off and on again / button = the user said so). " + "aborted/ holds\n" + "# tries that didn't finish (never packed). No names or other personal data are kept.\n"; + out << "format=1\n"; + out << "app_version=" << info.appVersion << "\n"; + out << "consent=" << info.consent << "\n"; + for (int i = 0; i < kStepCount; ++i) { + const Step& step = info.steps[i]; + const std::string key = "step" + std::to_string(i + 1) + "_"; + out << key << "protocol=" << (step.protocol.empty() ? protocolOf(i) : step.protocol.c_str()) << "\n"; + if (step.rec.empty()) continue; + out << key << "rec=" << step.rec << "\n"; + out << key << "bytes=" << step.bytes << "\n"; + out << key << "wear=" << step.wear << "\n"; + out << key << "done=" << step.done << "\n"; + } + if (!info.nextWear.empty()) out << "next_wear=" << info.nextWear << "\n"; + if (info.running >= 0) { + char since[64]; + std::snprintf(since, sizeof(since), "%.3f", info.runningSince); + out << "running=" << info.running + 1 << "\n"; + out << "running_since=" << since << "\n"; + } + for (const std::string& aborted : info.aborted) out << "aborted=" << aborted << "\n"; + if (!info.packed.empty()) { + out << "packed=" << info.packed << "\n"; + out << "packed_bytes=" << info.packedBytes << "\n"; + } + return out.str(); +} + +bool parseInfo(const std::string& text, Info& info) { + info = Info(); + std::istringstream lines(text); + std::string line; + while (std::getline(lines, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty() || line[0] == '#') continue; + const size_t equals = line.find('='); + if (equals == std::string::npos) continue; + const std::string key = line.substr(0, equals); + const std::string value = line.substr(equals + 1); + if (key == "app_version") { + info.appVersion = value; + } else if (key == "consent") { + info.consent = value; + } else if (key == "next_wear") { + info.nextWear = value; + } else if (key == "running") { + const long long step = readInteger(value, 0); + info.running = step >= 1 && step <= kStepCount ? static_cast(step - 1) : -1; + } else if (key == "running_since") { + info.runningSince = std::strtod(value.c_str(), nullptr); + } else if (key == "aborted") { + info.aborted.push_back(value); + } else if (key == "packed") { + info.packed = value; + } else if (key == "packed_bytes") { + info.packedBytes = readInteger(value, 0); + } else if (key.size() > 6 && key.compare(0, 4, "step") == 0 && key[5] == '_' && key[4] >= '1' && + key[4] < '1' + kStepCount) { + Step& step = info.steps[key[4] - '1']; + const std::string field = key.substr(6); + if (field == "protocol") step.protocol = value; + if (field == "rec") step.rec = value; + if (field == "bytes") step.bytes = readInteger(value, 0); + if (field == "wear") step.wear = value; + if (field == "done") step.done = value; + } + } + // A recording's name that isn't one (written by hand) doesn't count: that step is done again + for (Step& step : info.steps) { + const std::string name = step.rec; + if (!name.empty() && !isRecordingName(name)) step.rec.clear(); + } + return !info.consent.empty(); +} + +bool isFolderName(const std::string& name) { + return name.compare(0, 8, "contrib_") == 0 && stampAt(name, 8); +} + +bool isRecordingName(const std::string& name) { + return name.compare(0, 4, "rec_") == 0 && stampAt(name, 4); +} + +std::string folderName(std::time_t when) { + return "contrib_" + recorder::fileStamp(when); +} + +std::string localTime(std::time_t when, bool zone) { + std::tm local {}; + localtime_r(&when, &local); + char text[64]; + std::strftime(text, sizeof(text), zone ? "%Y-%m-%d %H:%M:%S %z" : "%Y-%m-%d %H:%M:%S", &local); + return text; +} + +std::string defaultHome() { + const char* home = std::getenv("HOME"); + return std::string(home != nullptr ? home : "") + "/eyecam"; +} + +std::string findCurrent(const std::string& home) { + std::vector found = names(home); + for (auto name = found.rbegin(); name != found.rend(); ++name) { + if (!isFolderName(*name) || !isRealDir(home + "/" + *name)) continue; + Info info; + std::string error; + if (readInfo(home + "/" + *name, info, error)) return home + "/" + *name; + } + return ""; +} + +bool readInfo(const std::string& dir, Info& info, std::string& error) { + std::string text; + const std::string path = dir + "/" + kInfoFile; + if (!file_io::read(path, text, 64 * 1024)) { + error = "can't read " + recorder::shortPath(path) + ": " + std::strerror(errno); + return false; + } + if (!parseInfo(text, info)) { + error = recorder::shortPath(path) + " has no consent"; + return false; + } + return true; +} + +bool writeInfo(const std::string& dir, const Info& info, std::string& error) { + const std::string path = dir + "/" + kInfoFile; + const std::string temp = path + ".tmp"; + const std::string text = formatInfo(info); + const int fd = ::open(temp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + if (fd < 0) { + error = "can't write " + recorder::shortPath(temp) + ": " + std::strerror(errno); + return false; + } + // (on the disk before the rename, so a crash never leaves an empty one) + if (!writeAll(fd, text.data(), text.size(), false) || ::fsync(fd) != 0) { + error = "can't write " + recorder::shortPath(temp) + ": " + std::strerror(errno); + ::close(fd); + ::unlink(temp.c_str()); + return false; + } + ::close(fd); + if (std::rename(temp.c_str(), path.c_str()) != 0) { + error = "can't replace " + recorder::shortPath(path) + ": " + std::strerror(errno); + ::unlink(temp.c_str()); + return false; + } + return true; +} + +bool create(const std::string& home, const std::string& version, std::time_t now, std::string& dir, Info& info, + std::string& error) { + if (::mkdir(home.c_str(), 0755) != 0 && errno != EEXIST) { + error = "can't create " + recorder::shortPath(home) + ": " + std::strerror(errno); + return false; + } + // (a second one in the same second gets the next second's name) + std::string path; + for (int later = 0;; ++later) { + path = home + "/" + folderName(now + later); + if (::mkdir(path.c_str(), 0755) == 0) break; + if (errno != EEXIST || later >= 60) { + error = "can't create " + recorder::shortPath(path) + ": " + std::strerror(errno); + return false; + } + } + Info made; + made.appVersion = version; + made.consent = localTime(now, true); + for (int step = 0; step < kStepCount; ++step) made.steps[step].protocol = protocolOf(step); + if (!writeInfo(path, made, error)) { + ::rmdir(path.c_str()); + return false; + } + dir = path; + info = made; + return true; +} + +long long freeBytes(const std::string& path) { + struct statvfs st {}; + if (::statvfs(path.c_str(), &st) != 0) return -1; + return static_cast(st.f_bavail) * static_cast(st.f_frsize); +} + +long long folderBytes(const std::string& path) { + long long total = 0; + for (const std::string& name : names(path)) { + const std::string child = path + "/" + name; + struct stat st {}; + if (::lstat(child.c_str(), &st) != 0) continue; + if (S_ISDIR(st.st_mode)) { + total += folderBytes(child); + } else if (S_ISREG(st.st_mode)) { + total += st.st_size; + } + } + return total; +} + +bool takeRecording(const std::string& home, const std::string& dir, const std::string& sessionDir, bool aborted, + std::string& name, std::string& error) { + const std::string base = baseName(sessionDir); + if (parentOf(sessionDir) != home || !isRecordingName(base) || !isRealDir(sessionDir)) { + error = "not one of eyecam-rec's recordings in " + recorder::shortPath(home) + ": " + sessionDir; + return false; + } + std::string into = dir; + if (aborted) { + into = dir + "/" + kAbortedDir; + if (::mkdir(into.c_str(), 0755) != 0 && errno != EEXIST) { + error = "can't create " + recorder::shortPath(into) + ": " + std::strerror(errno); + return false; + } + } + const std::string target = into + "/" + base; + if (::access(target.c_str(), F_OK) == 0) { + error = recorder::shortPath(target) + " is there already"; + return false; + } + if (std::rename(sessionDir.c_str(), target.c_str()) != 0) { + error = "can't move " + recorder::shortPath(sessionDir) + ": " + std::strerror(errno); + return false; + } + name = aborted ? std::string(kAbortedDir) + "/" + base : base; + return true; +} + +void RunWatch::begin(int step, const std::string& protocol, double sentUnix) { + step_ = step; + protocol_ = protocol; + sent_ = sentUnix; + seen_ = false; + staleSince_ = -1.0; +} + +RunEnd RunWatch::follow(const eyecam::Status& s, bool fresh, double nowUnix, double sessionMtime) { + using eyecam::State; + RunEnd end; + if (step_ < 0) return end; + // eyecam-rec gone (or stalled) for long: nothing will come of it + if (!fresh) { + if (staleSince_ < 0) staleSince_ = nowUnix; + if (nowUnix - staleSince_ >= kGoneSec) { + end.outcome = Outcome::Failed; + end.why = Why::Gone; + } + return end; + } + staleSince_ = -1.0; + const bool mine = s.protocol == protocol_; + if ((s.state == State::Searching || s.state == State::Recording) && mine) { + seen_ = true; + return end; + } + if (s.state != State::Idle && s.state != State::Error) { + // (something else runs: a calibration, or another recording before ours started) + if (!seen_ && nowUnix - sent_ >= kStartGraceSec) { + end.outcome = Outcome::Failed; + end.why = Why::NoStart; + } + return end; + } + // A folder made after the start is this one's (eyecam-rec clears session_dir when it starts) + const bool folder = !s.sessionDir.empty() && std::isfinite(sessionMtime) && sessionMtime >= sent_ - 2.0; + if (!seen_ && !(mine && folder)) { + // Not shown running yet: an older status, or a start that went wrong at once + if (nowUnix - sent_ >= kStartGraceSec) { + end.outcome = Outcome::Failed; + end.why = s.state == State::Error ? Why::Error : Why::NoStart; + end.message = s.state == State::Error ? s.message : std::string(); + end.messageEn = s.state == State::Error ? s.messageEn : std::string(); + } + return end; + } + end.dir = folder ? s.sessionDir : std::string(); + end.message = s.message; + end.messageEn = s.messageEn; + if (s.state == State::Error) { + end.outcome = Outcome::Failed; + end.why = Why::Error; + } else if (!folder) { + // Stopped while it searched (no folder), or its folder already gone + end.outcome = s.sessionDir.empty() ? Outcome::Stopped : Outcome::Failed; + end.why = s.sessionDir.empty() ? Why::Stopped : Why::Missing; + } else if (s.lastSessionAborted) { + end.outcome = Outcome::Stopped; + end.why = Why::Stopped; + } else { + end.outcome = Outcome::Done; + } + return end; +} + +void WearWatch::start(double now) { + *this = WearWatch(); + watching_ = true; + start_ = now; +} + +void WearWatch::follow(const eyecam::Status& s, bool fresh, double now) { + if (!watching_ || detected_) return; + // The loop stood still that long: the Frame slept (the headset off) + if (last_ >= 0 && now - last_ >= kOffSec) off_ = true; + last_ = now; + if (!fresh) { + proxOffSince_ = videoOffSince_ = onSince_ = -1.0; + return; + } + // The sensor: off only after it read worn (a face it never reaches the threshold on reads "off" all the time) + const bool proxKnown = std::isfinite(s.prox) && s.prox >= 0 && std::isfinite(s.proxMin); + const bool proxWorn = proxKnown && s.prox > s.proxMin; + proxWorn_ |= proxWorn; + if (proxKnown && !proxWorn && proxWorn_) { + if (proxOffSince_ < 0) proxOffSince_ = now; + if (now - proxOffSince_ >= kOffSec) off_ = true; + } else { + proxOffSince_ = -1.0; + } + // The eye video, while eyecam-rec looks at it (idle with live processing) + const bool videoKnown = s.live && s.state == eyecam::State::Idle; + if (videoKnown && !s.locked) { + if (videoOffSince_ < 0) videoOffSince_ = now; + if (now - videoOffSince_ >= kOffSec) off_ = true; + } else { + videoOffSince_ = -1.0; + } + // On again: the video back (or the sensor, without the video to go by) + const bool on = videoKnown ? s.locked : proxWorn; + if (off_ && on) { + if (onSince_ < 0) onSince_ = now; + if (now - onSince_ >= kOnSec) detected_ = true; + } else { + onSince_ = -1.0; + } +} + +Screen screen(const View& view, bool extra) { + if (view.progress.job == Job::Delete && view.progress.running) return Screen::Deleting; + if (view.dir.empty()) return Screen::Consent; + if (view.running >= 0) return Screen::Running; + const int step = nextStep(view.info, extra); + if (step >= kStepCount) return Screen::Done; + if (view.failure.step == step) return Screen::Failed; + return needsRewear(step) && view.info.nextWear.empty() ? Screen::Rewear : Screen::Ready; +} + +bool startAllowed(const View& view, const eyecam::View& eyecam, int step) { + const eyecam::State state = eyecam.status.state; + return !view.dir.empty() && step >= 0 && step < kStepCount && eyecam::setUp(eyecam) && !eyecam.busy && + (state == eyecam::State::Idle || state == eyecam::State::Error) && view.running < 0 && + !view.progress.running && fits(step, view.freeBytes, view.fps); +} + +bool packEntries(const std::string& dir, const Info& info, std::vector& entries, std::string& error) { + entries.clear(); + const std::string top = baseName(dir); + struct stat st {}; + if (::stat(dir.c_str(), &st) != 0) { + error = "can't read " + recorder::shortPath(dir) + ": " + std::strerror(errno); + return false; + } + entries.push_back({dir, top + "/", 0, st.st_mtime, true}); + const std::string infoPath = dir + "/" + kInfoFile; + if (::stat(infoPath.c_str(), &st) != 0) { + error = "can't read " + recorder::shortPath(infoPath) + ": " + std::strerror(errno); + return false; + } + entries.push_back({infoPath, top + "/" + kInfoFile, static_cast(st.st_size), st.st_mtime, false}); + for (const Step& step : info.steps) { + if (step.rec.empty()) continue; + const std::string rec = dir + "/" + step.rec; + if (!isRealDir(rec) || ::stat(rec.c_str(), &st) != 0) { + error = "the recording " + step.rec + " isn't there"; + return false; + } + entries.push_back({rec, top + "/" + step.rec + "/", 0, st.st_mtime, true}); + for (const std::string& name : names(rec)) { + const std::string path = rec + "/" + name; + if (::lstat(path.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) continue; + entries.push_back({path, top + "/" + step.rec + "/" + name, static_cast(st.st_size), + st.st_mtime, false}); + } + } + for (const Entry& entry : entries) { + if (entry.name.size() >= 100) { + error = "a name is too long for the pack: " + entry.name; + return false; + } + } + return true; +} + +std::string tarHeader(const Entry& entry) { + if (entry.name.empty() || entry.name.size() >= 100) return ""; + char header[512] = {}; + std::memcpy(header, entry.name.data(), entry.name.size()); + putOctal(header + 100, 8, entry.folder ? 0755 : 0644); + putOctal(header + 108, 8, 0); // (no owner: the files are the user's wherever they are unpacked) + putOctal(header + 116, 8, 0); + const unsigned long long size = entry.folder ? 0 : static_cast(std::max(0LL, entry.size)); + if (size < (1ULL << 33)) { + putOctal(header + 124, 12, size); + } else { + // GNU's base-256: the top bit of the first byte, then the size big-endian + header[124] = static_cast(0x80); + for (int i = 0; i < 8; ++i) header[135 - i] = static_cast((size >> (8 * i)) & 0xff); + } + putOctal(header + 136, 12, static_cast(std::max(0, entry.mtime))); + header[156] = entry.folder ? '5' : '0'; + std::memcpy(header + 257, "ustar", 6); + std::memcpy(header + 263, "00", 2); + // The checksum: the header's bytes with its own field as spaces + std::memset(header + 148, ' ', 8); + unsigned int sum = 0; + for (unsigned char c : header) sum += c; + std::snprintf(header + 148, 8, "%06o", sum); + header[155] = ' '; + return std::string(header, sizeof(header)); +} + +std::string packName(const std::string& dir, bool compressed) { + return baseName(dir) + (compressed ? ".tar.zst" : ".tar"); +} + +bool zstdAvailable() { + const char* path = std::getenv("PATH"); + const std::string dirs = path != nullptr ? path : "/usr/local/bin:/usr/bin:/bin"; + size_t from = 0; + while (from <= dirs.size()) { + const size_t colon = std::min(dirs.find(':', from), dirs.size()); + const std::string dir = dirs.substr(from, colon - from); + if (!dir.empty() && ::access((dir + "/zstd").c_str(), X_OK) == 0) return true; + from = colon + 1; + } + return false; +} + +Worker::~Worker() { + cancel_ = true; + if (thread_.joinable()) thread_.join(); +} + +void Worker::reap() { + if (thread_.joinable()) thread_.join(); + cancel_ = false; +} + +bool Worker::pack(const std::string& dir, const Info& info, bool compressed) { + if (progress().running) return false; + reap(); + std::vector entries; + std::string error; + Progress start; + start.job = Job::Pack; + start.output = packName(dir, compressed); + if (!packEntries(dir, info, entries, error)) { + std::lock_guard lock(mutex_); + progress_ = start; + progress_.finished = true; + progress_.error = error; + reported_ = false; + return false; + } + for (const Entry& entry : entries) start.total += entry.size; + start.running = true; + { + std::lock_guard lock(mutex_); + progress_ = start; + reported_ = true; + } + thread_ = std::thread([this, dir, entries, compressed]() { runPack(dir, entries, compressed); }); + return true; +} + +bool Worker::remove(const std::string& home, const std::string& dir) { + if (progress().running) return false; + reap(); + if (parentOf(dir) != home || !isFolderName(baseName(dir)) || !isRealDir(dir)) return false; + { + std::lock_guard lock(mutex_); + progress_ = Progress(); + progress_.job = Job::Delete; + progress_.running = true; + reported_ = true; + } + thread_ = std::thread([this, dir]() { + std::error_code error; + // (links in it are removed, never followed) + std::filesystem::remove_all(dir, error); + finish(!error, error ? "can't delete " + recorder::shortPath(dir) + ": " + error.message() : ""); + }); + return true; +} + +Progress Worker::progress() const { + std::lock_guard lock(mutex_); + return progress_; +} + +bool Worker::takeFinished(Progress& ended) { + std::lock_guard lock(mutex_); + if (reported_ || !progress_.finished) return false; + reported_ = true; + ended = progress_; + return true; +} + +void Worker::finish(bool ok, const std::string& error) { + std::lock_guard lock(mutex_); + progress_.running = false; + progress_.finished = true; + progress_.ok = ok; + progress_.error = error; + reported_ = false; +} + +void Worker::runPack(const std::string& dir, const std::vector& entries, bool compressed) { + // Below the panel's own loop and VR (Linux sets a thread's niceness on its own) + ::setpriority(PRIO_PROCESS, static_cast(::syscall(SYS_gettid)), kNice); + const std::string name = packName(dir, compressed); + const std::string final = dir + "/" + name; + const std::string part = final + ".part"; + const int out = ::open(part.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + if (out < 0) { + finish(false, "can't write " + recorder::shortPath(part) + ": " + std::strerror(errno)); + return; + } + // Through zstd: it reads a socket (so a zstd that died is an error here, never SIGPIPE) and writes the file + int sink = out; + pid_t child = -1; + if (compressed) { + int pair[2] = {-1, -1}; + const int devNull = ::open("/dev/null", O_RDWR | O_CLOEXEC); + if (devNull < 0 || ::socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, pair) != 0) { + const std::string why = std::strerror(errno); + if (devNull >= 0) ::close(devNull); + ::close(out); + ::unlink(part.c_str()); + finish(false, "can't start zstd: " + why); + return; + } + std::vector args = {"zstd", "-3", "-T2", "-q", "-c"}; + std::vector argv; + for (std::string& arg : args) argv.push_back(arg.data()); + argv.push_back(nullptr); + child = ::fork(); + if (child == 0) { + ::setpriority(PRIO_PROCESS, 0, kNice); + sigset_t none; + sigemptyset(&none); + ::sigprocmask(SIG_SETMASK, &none, nullptr); + ::dup2(pair[1], 0); + ::dup2(out, 1); + ::dup2(devNull, 2); + recorder::cloexecFrom3(); + ::execvp(argv[0], argv.data()); + ::_exit(127); + } + const std::string why = std::strerror(errno); + ::close(pair[1]); + ::close(devNull); + if (child < 0) { + ::close(pair[0]); + ::close(out); + ::unlink(part.c_str()); + finish(false, "can't start zstd: " + why); + return; + } + sink = pair[0]; + } + std::string error; + std::vector buffer(kChunk); + long long done = 0; + const auto put = [&](const char* data, size_t size) { + if (writeAll(sink, data, size, compressed)) return true; + error = compressed ? std::string("zstd stopped taking the data: ") + std::strerror(errno) + : "can't write " + recorder::shortPath(part) + ": " + std::strerror(errno); + return false; + }; + for (const Entry& entry : entries) { + if (cancel_) { + error = "stopped"; + break; + } + const std::string header = tarHeader(entry); + if (header.empty()) { + error = "a name is too long for the pack: " + entry.name; + break; + } + if (!put(header.data(), header.size())) break; + if (entry.folder) continue; + const int in = ::open(entry.path.c_str(), O_RDONLY | O_CLOEXEC); + if (in < 0) { + error = "can't read " + recorder::shortPath(entry.path) + ": " + std::strerror(errno); + break; + } + // As long as the header says: a file that shrank meanwhile is padded with zeros, one that grew is cut + long long left = entry.size; + while (left > 0 && error.empty() && !cancel_) { + const size_t want = static_cast(std::min(left, static_cast(kChunk))); + ssize_t n = ::read(in, buffer.data(), want); + if (n < 0 && errno == EINTR) continue; + if (n < 0) { + error = "can't read " + recorder::shortPath(entry.path) + ": " + std::strerror(errno); + break; + } + if (n == 0) { + std::fill(buffer.begin(), buffer.begin() + static_cast(want), 0); + n = static_cast(want); + } + if (!put(buffer.data(), static_cast(n))) break; + left -= n; + done += n; + std::lock_guard lock(mutex_); + progress_.done = done; + } + ::close(in); + if (!error.empty()) break; + if (cancel_) { + error = "stopped"; + break; + } + const size_t pad = static_cast((512 - entry.size % 512) % 512); + if (pad > 0) { + std::fill(buffer.begin(), buffer.begin() + static_cast(pad), 0); + if (!put(buffer.data(), pad)) break; + } + } + if (error.empty()) { + // The end: two empty blocks + std::fill(buffer.begin(), buffer.begin() + 1024, 0); + put(buffer.data(), 1024); + } + if (compressed) { + ::close(sink); + int status = 0; + while (::waitpid(child, &status, 0) < 0 && errno == EINTR) { + } + if (error.empty() && !(WIFEXITED(status) && WEXITSTATUS(status) == 0)) { + error = WIFEXITED(status) && WEXITSTATUS(status) == 127 + ? std::string("zstd couldn't be run") + : "zstd failed (exit " + std::to_string(WIFEXITED(status) ? WEXITSTATUS(status) : -1) + + "; is the disk full?)"; + } + } + if (error.empty() && ::fsync(out) != 0) { + error = "can't write " + recorder::shortPath(part) + ": " + std::strerror(errno); + } + struct stat st {}; + if (error.empty() && ::fstat(out, &st) != 0) error = std::strerror(errno); + ::close(out); + if (error.empty() && std::rename(part.c_str(), final.c_str()) != 0) { + error = "can't rename " + recorder::shortPath(part) + ": " + std::strerror(errno); + } + if (!error.empty()) { + ::unlink(part.c_str()); + finish(false, error); + return; + } + { + std::lock_guard lock(mutex_); + progress_.outputBytes = st.st_size; + } + finish(true, ""); +} + +void Session::load(View& view, const std::string& home) { + const Progress progress = view.progress; + const bool deleted = view.deleted; + view = View(); + view.loaded = true; + view.home = home; + view.progress = progress; + view.deleted = deleted; + view.compress = zstdAvailable(); + view.freeBytes = freeBytes(home); + run_.end(); + wear_.stop(); + command_.clear(); + view.dir = findCurrent(home); + if (view.dir.empty()) return; + view.deleted = false; + if (!readInfo(view.dir, view.info, view.error)) { + view.dir.clear(); + return; + } + view.totalBytes = folderBytes(view.dir); + // A recording it had started (the panel restarted meanwhile): followed on from when it was sent + if (view.info.running >= 0) { + run_.begin(view.info.running, protocolOf(view.info.running), view.info.runningSince); + view.running = view.info.running; + } +} + +bool Session::consent(View& view, const std::string& version, std::time_t now) { + if (!view.dir.empty()) return false; + std::string dir; + Info info; + if (!create(view.home, version, now, dir, info, view.error)) return false; + view.dir = dir; + view.info = info; + view.error.clear(); + view.failure = Failure(); + view.deleted = false; + view.progress = Progress(); + return true; +} + +bool Session::rewore(View& view) { + const int step = nextStep(view.info, true); + if (view.dir.empty() || view.running >= 0 || step >= kStepCount || !needsRewear(step) || + !view.info.nextWear.empty()) { + return false; + } + view.info.nextWear = kWearButton; + wear_.stop(); + save(view); + return true; +} + +std::string Session::startCommand(const View& view, const eyecam::View& eyecam, int step) const { + // Only the step whose turn it is (the optional one only after the others), with its re-wear done + if (step != nextStep(view.info, step >= kRequiredSteps) || !startAllowed(view, eyecam, step) || + !command_.empty() || (needsRewear(step) && view.info.nextWear.empty())) { + return ""; + } + return std::string(eyecam::kStartCommand) + " " + protocolOf(step); +} + +void Session::started(View& view, int step, const std::string& command, double sentUnix) { + command_ = command; + run_.begin(step, protocolOf(step), sentUnix); + view.running = step; + view.failure = Failure(); + view.info.running = step; + view.info.runningSince = sentUnix; + save(view); +} + +bool Session::replied(View& view, const eyecam::Reply& reply) { + if (command_.empty() || reply.command != command_) return false; + command_.clear(); + if (reply.ok) return false; + RunEnd end; + end.outcome = Outcome::Failed; + end.why = Why::Refused; + end.message = reply.error; + std::string log; + ended(view, end, log); + return true; +} + +bool Session::follow(View& view, const eyecam::View& eyecam, double nowUnix, double bootNow, std::string& log) { + bool changed = false; + if (bootNow - freeAt_ >= 2.0 && !view.home.empty()) { + freeAt_ = bootNow; + const long long free = freeBytes(view.home); + changed |= free != view.freeBytes; + view.freeBytes = free; + } + const double fps = eyecam::cameraFps(eyecam.status); + if (std::isfinite(fps) && fps > 0) view.fps = fps; + const bool fresh = eyecam::tabVisible(eyecam.status, nowUnix); + // The recording running + if (run_.active() && command_.empty()) { + double mtime = NAN; + struct stat st {}; + if (!eyecam.status.sessionDir.empty() && ::stat(eyecam.status.sessionDir.c_str(), &st) == 0) { + mtime = static_cast(st.st_mtime); + } + const RunEnd end = run_.follow(eyecam.status, fresh, nowUnix, mtime); + if (end.outcome != Outcome::Running) { + ended(view, end, log); + changed = true; + } + } + // The re-wear the next step waits for (the optional one's too, once the others are done) + const int next = nextStep(view.info, true); + if (!view.dir.empty() && view.running < 0 && next < kStepCount && needsRewear(next) && view.info.nextWear.empty()) { + if (!wear_.watching()) wear_.start(bootNow); + wear_.follow(eyecam.status, fresh, bootNow); + if (wear_.detected()) { + view.info.nextWear = kWearDetected; + wear_.stop(); + save(view); + log += "re-wear before step " + std::to_string(next + 1) + " seen\n"; + changed = true; + } + } else { + wear_.stop(); + } + const bool off = wear_.watching() && wear_.offSeen(); + const bool ready = wear_.fallbackReady(bootNow); + changed |= off != view.offSeen || ready != view.fallbackReady; + view.offSeen = off; + view.fallbackReady = ready; + // The pack or the delete + const Progress progress = worker_.progress(); + changed |= progress.running != view.progress.running || progress.done != view.progress.done || + progress.finished != view.progress.finished; + view.progress = progress; + Progress finished; + if (worker_.takeFinished(finished)) { + changed = true; + if (finished.job == Job::Pack && finished.ok) { + view.info.packed = finished.output; + view.info.packedBytes = finished.outputBytes; + view.totalBytes = folderBytes(view.dir); + save(view); + log += "packed into " + finished.output + " (" + sizeText(finished.outputBytes) + ")\n"; + } else if (finished.job == Job::Delete && finished.ok) { + log += "deleted " + recorder::shortPath(view.dir) + "\n"; + const std::string home = view.home; + view.deleted = true; + load(view, home); + view.deleted = view.dir.empty(); + } else { + log += std::string(finished.job == Job::Pack ? "pack" : "delete") + " failed: " + finished.error + "\n"; + } + } + return changed; +} + +bool Session::pack(View& view) { + if (view.dir.empty() || view.running >= 0 || requiredDone(view.info) < kRequiredSteps) return false; + const bool ok = worker_.pack(view.dir, view.info, view.compress); + view.progress = worker_.progress(); + return ok; +} + +bool Session::remove(View& view) { + if (view.dir.empty() || view.running >= 0) return false; + if (!worker_.remove(view.home, view.dir)) return false; + run_.end(); + wear_.stop(); + view.progress = worker_.progress(); + return true; +} + +bool Session::save(View& view) { + std::string error; + if (writeInfo(view.dir, view.info, error)) return true; + view.error = error; + return false; +} + +void Session::ended(View& view, const RunEnd& end, std::string& log) { + const int step = run_.step() >= 0 ? run_.step() : view.running; + run_.end(); + command_.clear(); + view.running = -1; + view.info.running = -1; + view.info.runningSince = 0; + if (step < 0 || step >= kStepCount) return; + const std::string label = "step " + std::to_string(step + 1); + if (end.outcome == Outcome::Done) { + std::string name; + std::string error; + if (takeRecording(view.home, view.dir, end.dir, false, name, error)) { + Step& done = view.info.steps[step]; + done.protocol = protocolOf(step); + done.rec = name; + done.bytes = folderBytes(view.dir + "/" + name); + done.wear = needsRewear(step) ? view.info.nextWear : std::string(kWearFirst); + done.done = localTime(std::time(nullptr), false); + view.info.nextWear.clear(); + view.failure = Failure(); + log += label + " recorded: " + name + " (" + sizeText(done.bytes) + ")\n"; + view.totalBytes = folderBytes(view.dir); + save(view); + return; + } + view.failure = {step, Why::Move, error, error, ""}; + view.info.aborted.push_back(recorder::shortPath(end.dir) + " not moved: " + error); + log += label + " recorded, but " + error + "\n"; + save(view); + return; + } + // Didn't finish: its part (if any) goes to aborted/, and the step can be tried again + view.failure = {step, end.why, end.message, end.messageEn, ""}; + std::string kept; + if (!end.dir.empty()) { + std::string name; + std::string error; + if (takeRecording(view.home, view.dir, end.dir, true, name, error)) { + kept = name; + view.failure.kept = name; + } else { + kept = recorder::shortPath(end.dir) + " (" + error + ")"; + } + } + const char* how = end.outcome == Outcome::Stopped ? "stopped" : "failed"; + if (!kept.empty()) view.info.aborted.push_back(kept + " " + how); + log += label + " " + how + (end.message.empty() ? "" : ": " + end.message) + + (kept.empty() ? "" : "; kept " + kept) + "\n"; + view.totalBytes = folderBytes(view.dir); + save(view); +} + +std::string sizeText(long long bytes) { + char text[32]; + if (bytes >= 1000LL * 1000 * 1000) { + std::snprintf(text, sizeof(text), "%.1f GB", bytes / 1e9); + } else { + std::snprintf(text, sizeof(text), "%.0f MB", std::max(0LL, bytes) / 1e6); + } + return text; +} + +} // namespace contrib diff --git a/panel/src/contrib.h b/panel/src/contrib.h new file mode 100644 index 0000000..2bca001 --- /dev/null +++ b/panel/src/contrib.h @@ -0,0 +1,689 @@ +// "Help train the AI" (Advanced tab, Debug tools): eye-camera recordings a user makes for the developer, to tune and +// train eyecam's half-shut check (the eyelid veto's CNN). Three recordings of eyecam's protocol without light, each in +// a wear of its own (the headset taken off and put back on before the next one), then one of free use; a second free +// use after them is optional. eyecam-rec records each into ~/eyecam/rec_/ as always (with its cues.csv, +// valve.csv, frames.csv and meta.txt); once it has ended the panel moves the folder into the contribution's folder +// ~/eyecam/contrib_/, whose info.txt keeps the progress (so it goes on after the panel or the Frame restarts) and +// says what is in it for the developer (no personal data). The folder can be packed into one file and deleted from the +// panel. Everything here works without OpenVR and cairo (contrib-test). +#pragma once + +#include "eyecam.h" + +#include +#include +#include +#include +#include +#include + +namespace contrib { + +/** The guided recordings' protocol (eyecam's protocol_widen_nolight.txt: no full-view light). */ +constexpr const char* kGuidedProtocol = "widen_nolight"; +/** The free use's (protocol_free.txt: 180 s of "normal", no prompts). */ +constexpr const char* kFreeProtocol = "free"; +/** How long the guided protocol's steps take (s; the lead-in included). */ +constexpr double kGuidedSec = 59.0; +/** ...and the free use's. */ +constexpr double kFreeSec = 180.0; +/** eyecam-rec records this much past a protocol's last step (s), for the end beep. */ +constexpr double kTailSec = 1.5; +/** The steps everyone is asked for: three guided, one free use. */ +constexpr int kRequiredSteps = 4; +/** ...and the optional second free use after them. */ +constexpr int kStepCount = 5; +/** What eyecam-rec writes per frame and eye (a 400x400 grey frame, its header, its rows), as eyecam-rec estimates. */ +constexpr double kFrameBytes = 160300.0; +/** The eye cameras' usual rate, for the estimates while eyecam-rec doesn't say. */ +constexpr double kDefaultFps = 90.0; +/** Space left free after everything (eyecam-rec stops a recording with less than 1 GiB left). */ +constexpr long long kMarginBytes = 1LL << 30; +/** The packed file is at most this share of the recordings (zstd -3 makes the eye video about 0.62 of its size). */ +constexpr double kPackShare = 0.75; +/** The headset counts as taken off once the eye video stopped (or the sensor read off) this long (s)... */ +constexpr double kOffSec = 3.0; +/** ...and as put back on once the video (or the sensor) was back this long after that (s). */ +constexpr double kOnSec = 2.0; +/** "I put it back on" can be pressed this long after a step began waiting for the re-wear (s). */ +constexpr double kFallbackSec = 20.0; +/** A start that eyecam-rec doesn't show running within this long (s) didn't start. */ +constexpr double kStartGraceSec = 8.0; +/** eyecam-rec silent (its status not fresh) this long (s) ends a recording as failed. */ +constexpr double kGoneSec = 10.0; +/** The contribution's file in its folder. */ +constexpr const char* kInfoFile = "info.txt"; +/** Where tries that didn't finish go in it (not packed; deleted with it). */ +constexpr const char* kAbortedDir = "aborted"; + +/** How the re-wear before a step was known (info.txt). */ +constexpr const char* kWearFirst = "first"; ///< the first step: the wear the user consented in +constexpr const char* kWearDetected = "detected"; ///< seen: off (the eye video or the sensor), then on again +constexpr const char* kWearButton = "button"; ///< "I put it back on" pressed + +/** One step as info.txt keeps it. */ +struct Step { + std::string protocol; ///< the protocol recorded ("" = not done) + std::string rec; ///< its recording's folder in the contribution's folder ("" = not done) + long long bytes = 0; ///< its size + std::string wear; ///< how the re-wear before it was known (kWear...) + std::string done; ///< when it ended (local "YYYY-MM-DD HH:MM:SS") +}; + +/** info.txt: the contribution's progress, and what is in it for the developer. */ +struct Info { + std::string appVersion; ///< the panel's version at the consent + std::string consent; ///< when the user agreed (local "YYYY-MM-DD HH:MM:SS +zzzz") + Step steps[kStepCount]; + std::string nextWear; ///< how the re-wear before the next step was known ("" = not yet; kWear...) + int running = -1; ///< the step whose recording was started (-1 = none) + double runningSince = 0; ///< ...when its start was sent (Unix seconds) + std::vector aborted; ///< tries that didn't finish ("rec_... stopped"), kept in aborted/ + std::string packed; ///< the one file packed from it, in its folder ("" = none) + long long packedBytes = 0; +}; + +/** + * The protocol of a step. + * @param step 0 .. kStepCount - 1 + * @return kGuidedProtocol for the first three, kFreeProtocol after them + */ +const char* protocolOf(int step); + +/** + * Whether a step is free use. + * @param step the step + * @return true for 3 and 4 + */ +bool isFree(int step); + +/** + * Whether a step waits for the headset to be put on again first: all but the first. + * @param step the step + * @return true for 1 and on + */ +bool needsRewear(int step); + +/** + * What a step's recording takes on the disk (eyecam-rec's own estimate: both eyes, every frame). + * @param step the step + * @param fps the eye cameras' frame rate (kDefaultFps when not a number above 0) + * @return bytes + */ +long long estimateBytes(int step, double fps); + +/** + * The step to do next: the first of the required ones not done; once they are, the optional one when it is asked for + * (extra) and not done. + * @param info the progress + * @param extra the optional free use was asked for + * @return the step, or kStepCount when there is none + */ +int nextStep(const Info& info, bool extra); + +/** + * How many of the required steps are done. + * @param info the progress + * @return 0 .. kRequiredSteps + */ +int requiredDone(const Info& info); + +/** + * The recordings' size so far. + * @param info the progress + * @return bytes + */ +long long recordedBytes(const Info& info); + +/** + * The space still needed for the required steps not done, with kMarginBytes. + * @param info the progress + * @param fps the eye cameras' frame rate + * @return bytes + */ +long long neededBytes(const Info& info, double fps); + +/** + * Whether a step's recording fits: its estimate and kMarginBytes are free (or the free space isn't known). + * @param step the step + * @param freeBytes the free space (-1 = unknown) + * @param fps the eye cameras' frame rate + * @return true if it fits + */ +bool fits(int step, long long freeBytes, double fps); + +/** + * What packing needs free: kPackShare of the recordings (all of them without zstd) and kMarginBytes. + * @param info the progress + * @param compressed packed with zstd + * @return bytes + */ +long long packNeedBytes(const Info& info, bool compressed); + +/** + * info.txt's text. + * @param info the progress + * @return the text (a comment, then "key=value" lines) + */ +std::string formatInfo(const Info& info); + +/** + * Read info.txt's text: "key=value" lines, "#" comments; unknown keys and odd lines are skipped. + * @param text the text + * @param info filled in + * @return false if it has no consent (not a contribution's) + */ +bool parseInfo(const std::string& text, Info& info); + +/** + * Whether a name is a contribution's folder: "contrib_YYYY-MM-DD_HH-MM-SS". + * @param name the name + * @return true if it is + */ +bool isFolderName(const std::string& name); + +/** + * Whether a name is eyecam-rec's recording folder: "rec_YYYY-MM-DD_HH-MM-SS". + * @param name the name + * @return true if it is + */ +bool isRecordingName(const std::string& name); + +/** + * A contribution's folder name for a time. + * @param when the time + * @return "contrib_YYYY-MM-DD_HH-MM-SS" (local) + */ +std::string folderName(std::time_t when); + +/** + * A local time as info.txt writes it. + * @param when the time + * @param zone with the time zone's offset (" +0900") + * @return "YYYY-MM-DD HH:MM:SS" (with the offset) + */ +std::string localTime(std::time_t when, bool zone); + +/** + * Where eyecam-rec records ($HOME/eyecam), and where the contributions go. + * @return the folder + */ +std::string defaultHome(); + +/** + * The contribution to show: the newest folder in home with a contribution's name and an info.txt that reads. + * @param home the folder + * @return its path, or "" for none + */ +std::string findCurrent(const std::string& home); + +/** + * Read a contribution's info.txt. + * @param dir its folder + * @param info filled in + * @param error why not + * @return true if read + */ +bool readInfo(const std::string& dir, Info& info, std::string& error); + +/** + * Write a contribution's info.txt (a temporary file, then renamed over it). + * @param dir its folder + * @param info the progress + * @param error why not + * @return true if written + */ +bool writeInfo(const std::string& dir, const Info& info, std::string& error); + +/** + * Start a contribution: its folder in home, and info.txt with the consent. + * @param home where it goes + * @param version the panel's version + * @param now the time of the consent + * @param dir its folder + * @param info its progress + * @param error why not + * @return true if made + */ +bool create(const std::string& home, const std::string& version, std::time_t now, std::string& dir, Info& info, + std::string& error); + +/** + * The free space where a folder is (for the user: what an unprivileged user can fill). + * @param path the folder + * @return bytes, or -1 if it can't be read + */ +long long freeBytes(const std::string& path); + +/** + * The size of the regular files in a folder and its folders (links not followed). + * @param path the folder + * @return bytes + */ +long long folderBytes(const std::string& path); + +/** + * Move a recording eyecam-rec has finished into the contribution's folder (aborted/ for one that didn't finish). Only + * one of eyecam-rec's own folders, right in home ("rec_"), is moved. + * @param home eyecam-rec's folder + * @param dir the contribution's folder + * @param sessionDir the recording's folder (status.json's session_dir) + * @param aborted into aborted/ + * @param name its name there (aborted/ included) + * @param error why not + * @return true if moved + */ +bool takeRecording(const std::string& home, const std::string& dir, const std::string& sessionDir, bool aborted, + std::string& name, std::string& error); + +/** How a recording of a step ended. */ +enum class Outcome { + Running, ///< not yet + Done, ///< it ran its full time + Stopped, ///< stopped early (the user's "Stop", the eyes lost): its folder (if any) holds part of it + Failed, ///< eyecam-rec failed, refused, went away, or never started it +}; + +/** Why a try didn't finish (the screen says it). */ +enum class Why { None, Stopped, Error, Refused, Gone, NoStart, Missing, Move }; + +/** A recording's end. */ +struct RunEnd { + Outcome outcome = Outcome::Running; + std::string dir; ///< its folder ("" = none was made) + Why why = Why::None; + std::string message; ///< eyecam-rec's (Japanese) or the reply's reason + std::string messageEn; ///< eyecam-rec's in English ("" = none) +}; + +/** + * Follows a step's recording from the moment its start went out until eyecam-rec has ended it, from status.json alone + * (also after the panel restarted in the middle of it: began again with the time the start was sent). + */ +class RunWatch { +public: + /** + * A start was sent. + * @param step the step + * @param protocol its protocol + * @param sentUnix when (Unix seconds) + */ + void begin(int step, const std::string& protocol, double sentUnix); + + /** It ended (or was given up). */ + void end() { step_ = -1; } + + /** @return true while following one */ + bool active() const { return step_ >= 0; } + + /** @return the step followed (-1 = none) */ + int step() const { return step_; } + + /** + * Follow it. It runs while eyecam-rec searches or records this protocol; it ended once eyecam-rec is idle or in + * error again, after showing it running, or with a recording folder made after the start (a panel that restarted + * meanwhile). Idle with the folder: Done, or Stopped when eyecam-rec says it ended early (last_session_aborted); + * idle without one: Stopped (stopped while it searched). In error: Failed. Not running kStartGraceSec after the + * start, or eyecam-rec silent kGoneSec: Failed. + * @param s eyecam-rec's status + * @param fresh it is fresh (eyecam::tabVisible) + * @param nowUnix Unix seconds + * @param sessionMtime the modification time of s.sessionDir (Unix seconds; NaN when it isn't there) + * @return how it stands + */ + RunEnd follow(const eyecam::Status& s, bool fresh, double nowUnix, double sessionMtime); + +private: + int step_ = -1; + std::string protocol_; + double sent_ = 0.0; + bool seen_ = false; ///< eyecam-rec was seen running it + double staleSince_ = -1.0; ///< since when its status isn't fresh (-1 = it is) +}; + +/** + * Watches for the headset being taken off and put back on before a step, from eyecam-rec's status: off once the eye + * video stopped for kOffSec (idle, with its live processing on, which keeps looking at it), the proximity sensor read + * off for kOffSec after it read worn (some faces never reach its threshold: then only the video counts), or the + * Frame slept for kOffSec (the panel's loop stood still: taking the headset off lets it sleep); then on again once + * the video (without live processing, the sensor) is back for kOnSec. + */ +class WearWatch { +public: + /** + * Start watching (a step began waiting for the re-wear). + * @param now seconds on a clock that runs through suspend + */ + void start(double now); + + /** Stop watching. */ + void stop() { watching_ = false; } + + /** + * Follow eyecam-rec (each time its status is read). + * @param s its status + * @param fresh it is fresh + * @param now seconds on a clock that runs through suspend + */ + void follow(const eyecam::Status& s, bool fresh, double now); + + /** @return true while watching */ + bool watching() const { return watching_; } + + /** @return true once the headset was seen off */ + bool offSeen() const { return off_; } + + /** @return true once it was seen off and then on again */ + bool detected() const { return detected_; } + + /** + * Whether "I put it back on" can be pressed: kFallbackSec after the watch started. + * @param now seconds on the same clock + * @return true once it can + */ + bool fallbackReady(double now) const { return watching_ && now - start_ >= kFallbackSec; } + +private: + bool watching_ = false; + double start_ = 0.0; + double last_ = -1.0; ///< the last follow (-1 = none yet) + bool proxWorn_ = false; ///< the sensor read worn since the watch started + double proxOffSince_ = -1.0; + double videoOffSince_ = -1.0; + double onSince_ = -1.0; + bool off_ = false; + bool detected_ = false; +}; + +/** What a try that didn't finish left for the screen. */ +struct Failure { + int step = -1; ///< the step (-1 = none) + Why why = Why::None; + std::string message; ///< eyecam-rec's (Japanese) or the reason + std::string messageEn; + std::string kept; ///< where its part went ("aborted/rec_..."; "" = none) +}; + +/** A job in the background. */ +enum class Job { None, Pack, Delete }; + +/** How the job stands (Worker::progress). */ +struct Progress { + Job job = Job::None; + bool running = false; + long long done = 0; ///< bytes packed so far + long long total = 0; ///< ...of these + bool finished = false; ///< it ended since it started (ok or error) + bool ok = false; + std::string error; ///< why it failed (English) + std::string output; ///< the packed file's name in the folder + long long outputBytes = 0; +}; + +/** The contribution as the panel shows it (PanelModel::contrib; the loop fills it in). */ +struct View { + bool loaded = false; ///< looked for since the panel started + std::string home; ///< eyecam-rec's folder (defaultHome) + std::string dir; ///< the contribution's folder ("" = none: the first screen) + Info info; + long long totalBytes = 0; ///< everything in its folder (the recordings, aborted/, a pack), as last counted + std::string error; ///< info.txt couldn't be written, a folder couldn't be made (English; "" = none) + long long freeBytes = -1; ///< free space there (-1 = unknown) + double fps = kDefaultFps; ///< the eye cameras' rate for the estimates + int running = -1; ///< the step recording now (-1 = none) + bool offSeen = false; ///< the step waiting for its re-wear saw the headset off + bool fallbackReady = false; ///< ..."I put it back on" can be pressed + Failure failure; ///< the last try that didn't finish + Progress progress; ///< packing or deleting + bool compress = true; ///< zstd is there (the pack is .tar.zst; else .tar) + bool deleted = false; ///< a contribution was just deleted (the first screen says so) +}; + +/** What the contribution's sheet shows. */ +enum class Screen { + Consent, ///< what is recorded and how it is used, and "I'll help" + Rewear, ///< take the headset off and put it back on + Ready, ///< the next step's start + Running, ///< its recording (the eye capture's run) + Failed, ///< the last try didn't finish: why, and again + Done, ///< all recorded: pack, send, delete + Deleting, ///< being deleted +}; + +/** + * The sheet's screen. + * @param view the contribution + * @param extra the optional free use was asked for + * @return the screen + */ +Screen screen(const View& view, bool extra); + +/** + * Whether a step's start can be pressed: eyecam-rec there and idle (or in error), set up, no command waiting, nothing + * running or being packed, and the step's recording fits. + * @param view the contribution + * @param eyecam eyecam-rec + * @param step the step + * @return true if it can + */ +bool startAllowed(const View& view, const eyecam::View& eyecam, int step); + +/** One file or folder in the pack. */ +struct Entry { + std::string path; ///< on the disk + std::string name; ///< in the archive ("contrib_.../rec_.../eye_L.raw"; folders end in "/") + long long size = 0; ///< bytes (0 for a folder) + std::time_t mtime = 0; + bool folder = false; +}; + +/** + * What a contribution's pack holds: its folder, info.txt, and each finished step's recording (its files sorted by + * name). Not aborted/ or an earlier pack. + * @param dir the contribution's folder + * @param info its progress + * @param entries filled in + * @param error why not (a recording missing, a name too long) + * @return true if all are there + */ +bool packEntries(const std::string& dir, const Info& info, std::vector& entries, std::string& error); + +/** + * A tar (ustar) header for an entry: sizes of 8 GiB and more in GNU's base-256. + * @param entry the entry (its name under 100 bytes) + * @return the 512 bytes, or "" for a name too long + */ +std::string tarHeader(const Entry& entry); + +/** + * The pack's file name. + * @param dir the contribution's folder + * @param compressed with zstd + * @return "contrib_.tar.zst" (or ".tar") + */ +std::string packName(const std::string& dir, bool compressed); + +/** + * Whether zstd can be run (in PATH). + * @return true if it can + */ +bool zstdAvailable(); + +/** + * Packs or deletes a contribution on a thread of its own, at a low priority, so the panel's loop never waits for it. + * The pack is written beside the recordings as ".part" and renamed once complete; a failed one is removed. The + * recordings themselves are never touched by a pack. + */ +class Worker { +public: + Worker() = default; + ~Worker(); + Worker(const Worker&) = delete; + Worker& operator=(const Worker&) = delete; + + /** + * Pack a contribution into one file in its folder (tar, through zstd -3 when compressed). + * @param dir its folder + * @param info its progress + * @param compressed through zstd + * @return false if a job runs, or what it holds can't be listed (progress says why) + */ + bool pack(const std::string& dir, const Info& info, bool compressed); + + /** + * Delete a contribution's folder (only one named like one, right in home). + * @param home eyecam-rec's folder + * @param dir its folder + * @return false if a job runs, or the folder isn't a contribution's + */ + bool remove(const std::string& home, const std::string& dir); + + /** @return how the job stands */ + Progress progress() const; + + /** + * The job that ended, once. + * @param ended filled in + * @return true if one ended since the last call + */ + bool takeFinished(Progress& ended); + +private: + mutable std::mutex mutex_; + Progress progress_; + bool reported_ = true; + std::thread thread_; + std::atomic cancel_ {false}; + + /** Join an ended thread before the next job. */ + void reap(); + + /** + * The pack's thread. + * @param dir the folder + * @param entries what goes in it + * @param compressed through zstd + */ + void runPack(const std::string& dir, const std::vector& entries, bool compressed); + + /** + * The end of a job. + * @param ok it went well + * @param error why not + */ + void finish(bool ok, const std::string& error); +}; + +/** + * The contribution as the panel's loop runs it: loads it, takes the consent and the buttons, follows its recordings + * and the re-wear between them in eyecam-rec's status, moves each recording into its folder, keeps info.txt, and runs + * the pack and the delete. Everything it shows goes into a View. + */ +class Session { +public: + /** + * Find the contribution to show (the newest in home), and go on with a recording it had started (the panel + * restarted meanwhile). + * @param view filled in + * @param home eyecam-rec's folder + */ + void load(View& view, const std::string& home); + + /** + * "I'll help": a new contribution's folder, with the consent's time. + * @param view the contribution (none yet) + * @param version the panel's version + * @param now the time + * @return false if it couldn't be made (view.error says why) + */ + bool consent(View& view, const std::string& version, std::time_t now); + + /** + * "I put it back on": the next step's re-wear counts as done. + * @param view the contribution + * @return false if no step waits for one + */ + bool rewore(View& view); + + /** + * The command that starts a step's recording, if it can start now (startAllowed, its turn, its re-wear done). + * @param view the contribution + * @param eyecam eyecam-rec + * @param step the step pressed + * @return "start ", or "" when it can't + */ + std::string startCommand(const View& view, const eyecam::View& eyecam, int step) const; + + /** + * The start went out: follow its recording (info.txt says so, for a restart). + * @param view the contribution + * @param step the step + * @param command the command sent + * @param sentUnix when (Unix seconds) + */ + void started(View& view, int step, const std::string& command, double sentUnix); + + /** + * A reply from eyecam-rec (any: only the start's counts). A refused start ends that try. + * @param view the contribution + * @param reply the reply + * @return true if it was the start's and it was refused + */ + bool replied(View& view, const eyecam::Reply& reply); + + /** + * Follow eyecam-rec and the background job (each time the status is read). + * @param view the contribution + * @param eyecam eyecam-rec + * @param nowUnix Unix seconds + * @param bootNow seconds on a clock that runs through suspend + * @param log what happened, a line each (for the panel's log; "" when nothing) + * @return true if the view changed + */ + bool follow(View& view, const eyecam::View& eyecam, double nowUnix, double bootNow, std::string& log); + + /** + * Pack it into one file (on the worker). + * @param view the contribution + * @return false if it can't start (view.progress says why) + */ + bool pack(View& view); + + /** + * Delete its folder (on the worker; not while a recording of it runs). + * @param view the contribution + * @return false if it can't + */ + bool remove(View& view); + +private: + RunWatch run_; + WearWatch wear_; + Worker worker_; + std::string command_; ///< the start waiting for its reply ("" = none) + double freeAt_ = -1e9; ///< when the free space was last read (boot seconds) + + /** + * Write info.txt (a failure goes to view.error). + * @param view the contribution + * @return true if written + */ + bool save(View& view); + + /** + * A recording of a step ended. + * @param view the contribution + * @param end how + * @param log what happened + */ + void ended(View& view, const RunEnd& end, std::string& log); +}; + +/** + * A size for the screen: "10.5 GB" (one decimal; MB under 1 GB). + * @param bytes the size + * @return the text + */ +std::string sizeText(long long bytes); + +} // namespace contrib diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index 921eb7e..8ce075e 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -876,6 +876,87 @@ UiText makeJapanese() { t.devTitle = "開発用"; t.devRecord = "目の撮影"; t.devRecordHint = "目のカメラの映像を録る"; + t.contribRow = "AI の学習に協力する"; + t.contribRowHint = "半目の読み直し(AI)のための録画"; + t.contribOpen = "開く"; + t.contribRowIntroFormat = "目の映像を録って開発者に送る手伝い(任意・約 %s)"; + t.contribRowProgressFormat = "%d / %d 回録れたよ。続きからできるよ"; + t.contribRowDone = "全部録れたよ。まだなら開発者に送ってね"; + t.contribRowNotSetUp = "目のカメラの準備が済むと使えるよ"; + t.contribChips[0] = "決まった動き 1"; + t.contribChips[1] = "決まった動き 2"; + t.contribChips[2] = "決まった動き 3"; + t.contribChips[3] = "ふだん使い"; + t.contribChips[4] = "ふだん使い(追加)"; + t.contribLead = "「半目の読み直し(AI)」を、いろんな人の目で学習させるための録画だよ。" + "協力するかどうかは自由です。"; + t.contribWhatTitle = "録るもの"; + t.contribWhatFormat = "目のカメラの映像(約 1 分を 3 回と、ふだん使い 3 分)。全部で約 %s。" + "送るまでは、この Frame の中だけにあるよ。"; + t.contribUseTitle = "使いみち"; + t.contribUse = "半目の見分けの調整と、AI の学習だけに使います。アプリに入るのは学習した AI だけで、" + "映像は入りません。映像は開発者の PC にだけ置きます。"; + t.contribRemoveTitle = "消してほしいとき"; + t.contribRemove = "言ってくれれば消します。ただ、もう配った AI に入った学習の分は取り出せません。"; + t.contribNote = "画面が白や黒に光る段はないよ(光なしで撮るよ)。全部で 15 分くらい。" + "途中でやめても、あとで続きからできるよ。"; + t.contribSpaceFormat = "空き容量 %s(要るのは約 %s)"; + t.contribSpaceShortFormat = "空きが足りないよ。あと %s 空けてね"; + t.contribAgree = "協力する"; + t.contribCancel = "やめる"; + t.contribDeleted = "録画を消したよ"; + t.contribGuidedFormat = "決まった動き(%d / %d)"; + t.contribFreeTitle = "ふだん使い(3 分)"; + t.contribFreeExtraTitle = "ふだん使い・追加(3 分)"; + t.contribRewearTitle = "HMD をいったん外して、もう一度かぶってね"; + t.contribRewearWhy = "かぶるたびにカメラの映り方が少し変わるのが、学習に大事なんだ。"; + t.contribRewearWaiting = "かぶり直したら、またこの画面を開いてね。外したのも、かぶったのも自動で分かるよ。"; + t.contribRewearOff = "外したのが分かったよ。かぶって少し待ってね"; + t.contribRewore = "かぶり直した"; + t.contribReworeHint = "自動で分からないときは、かぶり直してからこれを押してね"; + t.contribReworeWait = "かぶり直してから押せるようになるよ"; + t.contribWearDone = "かぶり直したのが分かったよ"; + t.contribGuidedNote = "ビープと画面の指示に合わせて、目を閉じる・普通・見開くを 3 回、" + "そのあと細める・上を見る・下を見るをするよ。約 1 分。"; + t.contribGuidedStart = "撮影をはじめる(約 1 分)"; + t.contribFreeTips[0] = "いつもどおり VR を使ってね。ダッシュボードは閉じて大丈夫"; + t.contribFreeTips[1] = "3〜4 回、左右の耳のほうを 3 秒くらい見てね"; + t.contribFreeTips[2] = "何回か、目を開けたまま下(手元)を見てね"; + t.contribFreeTips[3] = "話したり笑ったりして大丈夫。まばたきも自然にね"; + t.contribFreeEnd = "始まりにビープが 1 回、終わると低いビープが 2 回鳴るよ"; + t.contribFreeStart = "3 分の撮影をはじめる"; + t.contribNoSpaceFormat = "空きが足りないよ(約 %s 要る)"; + t.contribWaitEyecam = "目のカメラがほかのことをしているあいだは待ってね"; + t.contribBack = "戻る"; + t.contribRunFormat = "AI の学習に協力する・%s"; + t.contribFreeRunning = "ふだんどおり使ってね"; + t.contribFailedTitle = "最後まで録れなかったよ"; + t.contribWhyStopped = "途中で止まったよ(中止したか、目が見えなくなったみたい)"; + t.contribWhyErrorFormat = "目の撮影でエラー: %s"; + t.contribWhyRefusedFormat = "始められなかった: %s"; + t.contribWhyGone = "目の撮影(eyecam-rec)が止まったみたい"; + t.contribWhyNoStart = "撮影が始まらなかったよ"; + t.contribWhyMissing = "録画のフォルダが見つからなかったよ"; + t.contribWhyMoveFormat = "録画を移せなかった: %s"; + t.contribKept = "途中までの分は aborted/ に入れたよ(送らなくて大丈夫)"; + t.contribRetry = "もう一度録る"; + t.contribDoneTitle = "全部録れたよ。ありがとう!"; + t.contribTotalFormat = "合計 %s"; + t.contribWhereFormat = "場所: %s"; + t.contribNext = "1 つのファイルにまとめて、Google ドライブなどで開発者に送ってね。"; + t.contribPack = "1 つのファイルにまとめる"; + t.contribPackHintFormat = "元の録画はそのまま残るよ(空きが約 %s 要る)"; + t.contribPackingFormat = "まとめています… %d%%"; + t.contribPackedFormat = "できたよ(%s)"; + t.contribPackFailedFormat = "まとめられなかった: %s"; + t.contribPackNoSpaceFormat = "まとめるには空きが足りないよ(約 %s 要る)"; + t.contribExtra = "もう 1 回ふだん使いを録る(任意)"; + t.contribDelete = "この録画を消す"; + t.contribDeleteTitle = "この録画を消す?"; + t.contribDeleteFormat = "%s を中身ごと(%s)消します。元に戻せないよ。開発者に送ったあとで消してね。"; + t.contribDeleteYes = "消す"; + t.contribDeleting = "消しています…"; + t.contribErrorFormat = "うまくいかなかった: %s"; t.rowPrefix = "パラメーター名の頭"; t.prefixNone = "なし"; t.prefixExample = "例: "; @@ -1848,6 +1929,90 @@ UiText makeEnglish() { t.devTitle = "Developer"; t.devRecord = "Eye recording"; t.devRecordHint = "Records the eye cameras"; + t.contribRow = "Help train the AI"; + t.contribRowHint = "Recordings for the half-shut check"; + t.contribOpen = "Open"; + t.contribRowIntroFormat = "Record your eyes and send them to the developer (optional, about %s)"; + t.contribRowProgressFormat = "%d of %d recorded. You can carry on where you left off"; + t.contribRowDone = "All recorded. Send it to the developer if you haven't yet"; + t.contribRowNotSetUp = "Available once the eye cameras are set up"; + t.contribChips[0] = "Guided 1"; + t.contribChips[1] = "Guided 2"; + t.contribChips[2] = "Guided 3"; + t.contribChips[3] = "Free use"; + t.contribChips[4] = "Free use (extra)"; + t.contribLead = "Recordings to train the \"Half-shut check (AI)\" on more people's eyes. Taking part is entirely " + "up to you."; + t.contribWhatTitle = "What"; + t.contribWhatFormat = "Eye camera video (about 1 minute, 3 times, and 3 minutes of normal use). About %s in all. " + "It stays on this Frame until you send it."; + t.contribUseTitle = "How it's used"; + t.contribUse = "Only to tune the half-shut check and train its AI. Only the trained AI goes into the app, never " + "the video. The video is kept on the developer's own PCs."; + t.contribRemoveTitle = "Removing it"; + t.contribRemove = "Ask and it is deleted. What an AI already released has learned from it can't be taken back " + "out."; + t.contribNote = "No white or black screens (recorded without light). About 15 minutes in all. You can stop and " + "carry on later."; + t.contribSpaceFormat = "%s free (about %s needed)"; + t.contribSpaceShortFormat = "Not enough space. Free up %s more"; + t.contribAgree = "I'll help"; + t.contribCancel = "Cancel"; + t.contribDeleted = "Recordings deleted"; + t.contribGuidedFormat = "Guided recording (%d of %d)"; + t.contribFreeTitle = "Normal use (3 minutes)"; + t.contribFreeExtraTitle = "Normal use, extra (3 minutes)"; + t.contribRewearTitle = "Take the headset off, then put it back on"; + t.contribRewearWhy = "Each wear sits the cameras a little differently, and that is what the AI needs to learn."; + t.contribRewearWaiting = "Open this screen again once it's back on. Taking it off and putting it on are noticed " + "by themselves."; + t.contribRewearOff = "Seen off. Put it back on and wait a moment"; + t.contribRewore = "I put it back on"; + t.contribReworeHint = "If it isn't noticed, press this after putting it back on"; + t.contribReworeWait = "Can be pressed once you've put it back on"; + t.contribWearDone = "Put back on: noticed"; + t.contribGuidedNote = "Follow the beeps and the prompts: close, normal and wide eyes 3 times, then squint, look up " + "and look down. About 1 minute."; + t.contribGuidedStart = "Start recording (about 1 minute)"; + t.contribFreeTips[0] = "Use VR as usual. You can close the dashboard"; + t.contribFreeTips[1] = "3 or 4 times, look far toward each ear for about 3 seconds"; + t.contribFreeTips[2] = "A few times, look down (at your hands) with your eyes open"; + t.contribFreeTips[3] = "Talking and laughing are fine. Blink naturally"; + t.contribFreeEnd = "One beep when it starts, two low beeps when it ends"; + t.contribFreeStart = "Start the 3-minute recording"; + t.contribNoSpaceFormat = "Not enough space (about %s needed)"; + t.contribWaitEyecam = "Wait while the eye cameras are busy with something else"; + t.contribBack = "Back"; + t.contribRunFormat = "Help train the AI: %s"; + t.contribFreeRunning = "Use VR as usual"; + t.contribFailedTitle = "It didn't record to the end"; + t.contribWhyStopped = "It stopped early (stopped, or the eyes were lost)"; + t.contribWhyErrorFormat = "Eye recording error: %s"; + t.contribWhyRefusedFormat = "Couldn't start: %s"; + t.contribWhyGone = "The eye recorder (eyecam-rec) seems to have stopped"; + t.contribWhyNoStart = "The recording didn't start"; + t.contribWhyMissing = "The recording's folder wasn't found"; + t.contribWhyMoveFormat = "Couldn't move the recording: %s"; + t.contribKept = "What was recorded is in aborted/ (no need to send it)"; + t.contribRetry = "Record again"; + t.contribDoneTitle = "All recorded. Thank you!"; + t.contribTotalFormat = "%s in all"; + t.contribWhereFormat = "Folder: %s"; + t.contribNext = "Make it one file and share it with the developer (for example on Google Drive)."; + t.contribPack = "Make one file"; + t.contribPackHintFormat = "The recordings stay as they are (needs about %s free)"; + t.contribPackingFormat = "Packing… %d%%"; + t.contribPackedFormat = "Done (%s)"; + t.contribPackFailedFormat = "Couldn't pack it: %s"; + t.contribPackNoSpaceFormat = "Not enough space to pack it (about %s needed)"; + t.contribExtra = "Record normal use once more (optional)"; + t.contribDelete = "Delete these recordings"; + t.contribDeleteTitle = "Delete these recordings?"; + t.contribDeleteFormat = "Deletes %s and everything in it (%s). This can't be undone: delete it after sending it to " + "the developer."; + t.contribDeleteYes = "Delete"; + t.contribDeleting = "Deleting…"; + t.contribErrorFormat = "Something went wrong: %s"; t.rowPrefix = "Parameter prefix"; t.prefixNone = "None"; t.prefixExample = "e.g. "; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index 5cf5a2d..5c1924f 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -882,6 +882,76 @@ struct UiText { const char* devTitle; ///< Advanced tab: the developer section (eye recording) const char* devRecord; ///< ...its row const char* devRecordHint; + // "Help train the AI" (contrib.h): its row on Debug tools, and its sheet + const char* contribRow; ///< the row's title, and the sheet's + const char* contribRowHint; ///< ...under the title + const char* contribOpen; ///< ...its button + const char* contribRowIntroFormat; ///< ...beside it before one is begun (%s: the size) + const char* contribRowProgressFormat; ///< ...while one goes on (%d done of %d) + const char* contribRowDone; ///< ...once all are recorded + const char* contribRowNotSetUp; ///< ...before the eye cameras are set up (the button greyed out) + const char* contribChips[5]; ///< the steps' chips under the title + const char* contribLead; ///< the consent: what it is, and that it is optional + const char* contribWhatTitle; ///< ...what is recorded + const char* contribWhatFormat; ///< ...(%s: the size of all) + const char* contribUseTitle; ///< ...how it is used + const char* contribUse; + const char* contribRemoveTitle; ///< ...deleting it later + const char* contribRemove; + const char* contribNote; ///< ...no light, how long, can be continued + const char* contribSpaceFormat; ///< ...the free space (%s free, %s needed) + const char* contribSpaceShortFormat; ///< ...not enough (%s more to free) + const char* contribAgree; ///< ...the consent's button + const char* contribCancel; ///< ...and closing without it + const char* contribDeleted; ///< the first screen again, right after a delete + const char* contribGuidedFormat; ///< a guided step's title (%d of %d) + const char* contribFreeTitle; ///< the free use's title + const char* contribFreeExtraTitle; ///< ...the optional second one's + const char* contribRewearTitle; ///< take the headset off and put it back on + const char* contribRewearWhy; + const char* contribRewearWaiting; ///< ...not seen off yet + const char* contribRewearOff; ///< ...seen off + const char* contribRewore; ///< "I put it back on" + const char* contribReworeHint; ///< ...beside it + const char* contribReworeWait; ///< ...beside it while it can't be pressed yet + const char* contribWearDone; ///< the step's start: the re-wear is done + const char* contribGuidedNote; ///< ...what a guided recording asks + const char* contribGuidedStart; + const char* contribFreeTips[4]; ///< ...what the free use asks + const char* contribFreeEnd; ///< ...how it ends + const char* contribFreeStart; + const char* contribNoSpaceFormat; ///< ...the recording doesn't fit (%s needed) + const char* contribWaitEyecam; ///< ...eyecam-rec busy with something else + const char* contribBack; ///< the optional step: back to the end + const char* contribRunFormat; ///< the recording's section title (%s: the step) + const char* contribFreeRunning; ///< the free use's instruction while it records + const char* contribFailedTitle; ///< a try that didn't finish + const char* contribWhyStopped; + const char* contribWhyErrorFormat; ///< (%s: eyecam-rec's message) + const char* contribWhyRefusedFormat; ///< (%s: its reply) + const char* contribWhyGone; + const char* contribWhyNoStart; + const char* contribWhyMissing; + const char* contribWhyMoveFormat; ///< (%s: why) + const char* contribKept; ///< ...its part went to aborted/ + const char* contribRetry; + const char* contribDoneTitle; ///< all recorded + const char* contribTotalFormat; ///< (%s: the size) + const char* contribWhereFormat; ///< (%s: the folder) + const char* contribNext; ///< what to do with it + const char* contribPack; ///< "Make one file" + const char* contribPackHintFormat; ///< ...beside it (%s: the size it needs free) + const char* contribPackingFormat; ///< ...while packing (%d %) + const char* contribPackedFormat; ///< ...done (%s: its size; its name under it) + const char* contribPackFailedFormat; ///< ...failed (%s why) + const char* contribPackNoSpaceFormat; ///< ...not enough space (%s needed) + const char* contribExtra; ///< the optional free use + const char* contribDelete; ///< "Delete these recordings" + const char* contribDeleteTitle; ///< ...its confirmation + const char* contribDeleteFormat; ///< ...(%s folder, %s size) + const char* contribDeleteYes; + const char* contribDeleting; + const char* contribErrorFormat; ///< something on the disk failed (%s why) const char* rowPrefix; const char* prefixNone; const char* prefixExample; ///< "e.g." before an OSC address diff --git a/panel/src/main.cpp b/panel/src/main.cpp index b7954f0..053921c 100644 --- a/panel/src/main.cpp +++ b/panel/src/main.cpp @@ -164,6 +164,7 @@ struct Options { std::string fakeEyecam; ///< a made-up eyecam-rec state (see printUsage and parseFakeEyecam) std::string fakeCamera; ///< --fake-camera: the eye cameras as frameeyeosc reports them (see printUsage) std::string fakePassword = "set"; ///< --fake-password: the setup's password check + std::string fakeContrib; ///< --fake-contrib: "Help train the AI" open on a made-up screen (see fakeContrib) double sensitivityDrag = -1; ///< --sensitivity-drag: the slider as if dragged there (-1 = not) std::string eyecamDir; ///< --eyecam-dir: eyecam-rec's folder (status.json, ctl.sock) instead of the default std::string fillPngPath; ///< --eyecam-fill-png: the eye capture's full-view light image @@ -383,6 +384,11 @@ void printUsage() { " 60). The last calibration follows setup, failed=... and calib-error\n" " --sensitivity-drag V Draw the widening sensitivity slider as if dragged to V (with sens=...)\n" " --fake-password set|unset|unknown The setup's password check (default: set)\n" + " --fake-contrib STATE \"Help train the AI\" open on a made-up screen (the eye cameras set up and idle\n" + " unless --fake-eyecam says otherwise): consent, nospace, deleted, rewear,\n" + " rewear-off (seen off; \"I put it back on\" usable), ready, ready-free, running,\n" + " running-free, failed, failed-error, done, packing, packed, extra, delete, deleting\n" + " (with --adv-page tools: its row on Debug tools in that state, the sheet closed)\n" " --fake-camera both|left|right|uncalibrated|absent|error|off The eye cameras as frameeyeosc reports\n" " them (off: camera_lids off)\n" " --eyecam-fill-png PATH Draw the eye capture's full-view light (with --language) to a PNG\n" @@ -452,6 +458,11 @@ bool parsePreviewSpec(const std::string& text, eye_preview::Source& source, bool } } +/** --fake-contrib's screens (fakeContrib). */ +const std::string kFakeContribStates[] = {"consent", "nospace", "deleted", "rewear", "rewear-off", "ready", + "ready-free", "running", "running-free", "failed", "failed-error", "done", + "packing", "packed", "extra", "delete", "deleting"}; + /** --fake-eyecam, taken apart. */ struct FakeEyecam { std::string state; ///< waiting, idle, confirm, searching, recording, calibrating, error, calib-error @@ -1092,6 +1103,15 @@ bool parseOptions(int argc, char** argv, Options& options) { return false; } options.fake = true; + } else if (arg == "--fake-contrib" && hasNext) { + options.fakeContrib = argv[++i]; + if (std::find(std::begin(kFakeContribStates), std::end(kFakeContribStates), options.fakeContrib) == + std::end(kFakeContribStates)) { + std::fprintf(stderr, "--fake-contrib must be one of the states in --help: %s\n", + options.fakeContrib.c_str()); + return false; + } + options.fake = true; } else if (arg == "--sensitivity-drag" && hasNext) { options.sensitivityDrag = std::atof(argv[++i]); } else if (arg == "--fake-camera" && hasNext) { @@ -1626,6 +1646,94 @@ eyecam::View fakeEyecam(const std::string& text) { return view; } +/** + * --fake-contrib: "Help train the AI" on one of its screens (kFakeContribStates), made up: nothing is read or written. + * The eye cameras set up and idle, unless --fake-eyecam gave eyecam-rec already; recording for the running ones. + * @param m the model + * @param state the screen + * @param eyecamGiven --fake-eyecam was given + */ +void fakeContrib(PanelModel& m, const std::string& state, bool eyecamGiven) { + contrib::View& c = m.contrib; + c.loaded = true; + c.home = "/home/steamos/eyecam"; + c.freeBytes = state == "nospace" ? 7'400'000'000LL : 23'200'000'000LL; + c.fps = 90.0; + if (!eyecamGiven) m.eyecam = fakeEyecam("idle:setup:auto=ok:buffers:ready:saved"); + if (state == "consent" || state == "nospace" || state == "deleted") { + c.deleted = state == "deleted"; + return; + } + c.dir = "/home/steamos/eyecam/contrib_2026-10-25_14-02-10"; + c.info.appVersion = "0.8.2-beta.2"; + c.info.consent = "2026-10-25 14:02:10 +0900"; + // The steps done: one before the second guided recording, three before the free use, all for the end + const bool end = state == "done" || state == "packing" || state == "packed" || state == "extra" || + state == "delete" || state == "deleting"; + const int done = end ? contrib::kRequiredSteps : (state == "ready-free" || state == "running-free") ? 3 : 1; + const char* const recs[] = {"rec_2026-10-25_14-02-41", "rec_2026-10-25_14-05-12", "rec_2026-10-25_14-08-03", + "rec_2026-10-25_14-11-40"}; + for (int step = 0; step < contrib::kStepCount; ++step) { + contrib::Step& s = c.info.steps[step]; + s.protocol = contrib::protocolOf(step); + if (step >= done) continue; + s.rec = recs[step]; + s.bytes = contrib::estimateBytes(step, 90.0) - 3'000'000; + s.wear = step == 0 ? contrib::kWearFirst : contrib::kWearDetected; + s.done = "2026-10-25 14:0" + std::to_string(3 + step * 3) + ":44"; + } + c.totalBytes = contrib::recordedBytes(c.info); + if (state != "rewear" && state != "rewear-off") c.info.nextWear = contrib::kWearDetected; + c.offSeen = state == "rewear-off"; + c.fallbackReady = state == "rewear-off"; + if (state == "running" || state == "running-free") { + c.running = done; + if (state == "running") { + m.eyecam = fakeEyecam("recording:widen:nolight"); + } else { + // The free use: protocol_free.txt's one step of 180 s, a minute in + m.eyecam = fakeEyecam("recording:normal"); + eyecam::Status& s = m.eyecam.status; + s.protocol = contrib::kFreeProtocol; + s.stepCount = 1; + s.stepIndex = 0; + s.totalS = contrib::kFreeSec + contrib::kTailSec; + s.elapsedS = 63.0; + s.stepRemainingS = contrib::kFreeSec - 63.0; + m.eyecam.protocol.name = s.protocol; + m.eyecam.protocol.steps = {"normal"}; + } + } + if (state == "failed" || state == "failed-error") { + c.failure.step = done; + if (state == "failed") { + c.failure.why = contrib::Why::Stopped; + c.failure.kept = "aborted/rec_2026-10-25_14-05-12"; + } else { + c.failure.why = contrib::Why::Error; + // (eyecam-rec's own, with its English) + c.failure.message = "ディスクの空きが 1 GB 未満(途中まで保存: rec_2026-10-25_14-05-12)"; + c.failure.messageEn = "Less than 1 GB of disk space left (saved so far: rec_2026-10-25_14-05-12)"; + c.failure.kept = "aborted/rec_2026-10-25_14-05-12"; + } + } + if (state == "packing") { + c.progress.job = contrib::Job::Pack; + c.progress.running = true; + c.progress.total = c.totalBytes; + c.progress.done = c.totalBytes * 45 / 100; + } + if (state == "packed") { + c.info.packed = "contrib_2026-10-25_14-02-10.tar.zst"; + c.info.packedBytes = c.totalBytes * 62 / 100; + c.totalBytes += c.info.packedBytes; + } + if (state == "deleting") { + c.progress.job = contrib::Job::Delete; + c.progress.running = true; + } +} + /** * Copy where a command to eyecam-rec is (waiting, or its reply) into the view the panel draws. * @param view the view @@ -1965,6 +2073,7 @@ PanelModel fakeModel(const Options& options) { m.preview.hasPacket = false; } } + if (!options.fakeContrib.empty()) fakeContrib(m, options.fakeContrib, !options.fakeEyecam.empty()); m.eyecam.password = options.fakePassword == "unset" ? eyecam::PasswordState::NotSet : options.fakePassword == "unknown" ? eyecam::PasswordState::Unknown : eyecam::PasswordState::Set; @@ -1982,7 +2091,7 @@ PanelModel fakeModel(const Options& options) { void applyHit(const PanelHit& hit, PanelModel& model, EyePanel& panel, AutostartWorker& autostart, frame_updater::UpdateChecker* updater, gaze_fit::Session* fit, const VrOverlay* vr, - recorder::Recorder* eyeLog, eyecam::Control* eyecamControl); + recorder::Recorder* eyeLog, eyecam::Control* eyecamControl, contrib::Session* contribSession = nullptr); std::string targetLabel(const UiText& t, gaze_fit::TargetStyle style); /** @@ -2247,6 +2356,12 @@ int runDumpPng(const Options& options) { if (options.historyScroll >= 0) panel.setHistoryScroll(options.historyScroll); } if (options.fakeEyecam.rfind("confirm", 0) == 0) panel.openEyecamConfirm(model.eyecam.status.state); + // (with --adv-page tools: its row on Debug tools, the sheet closed) + if (!options.fakeContrib.empty() && options.advPage != "tools") { + panel.openContrib(); + panel.setContribExtra(options.fakeContrib == "extra"); + if (options.fakeContrib == "delete") panel.openContribDelete(); + } if (!options.appsSheet.empty()) { const std::string& sheet = options.appsSheet; panel.openAppsList(); @@ -2803,10 +2918,11 @@ void startFit(gaze_fit::Session& fit, gaze_fit::Mode mode, const SettingsView& v * @param vr the connection to SteamVR, for the IPD (null in --dump-png) * @param eyeLog the eye log (null in --dump-png) * @param eyecamControl eyecam-rec's control socket (null: its buttons do nothing) + * @param contribSession "Help train the AI" (null in --dump-png: its buttons only say what they would do) */ void applyHit(const PanelHit& hit, PanelModel& model, EyePanel& panel, AutostartWorker& autostart, frame_updater::UpdateChecker* updater, gaze_fit::Session* fit, const VrOverlay* vr, - recorder::Recorder* eyeLog, eyecam::Control* eyecamControl) { + recorder::Recorder* eyeLog, eyecam::Control* eyecamControl, contrib::Session* contribSession) { const SettingsView view(model); std::function change; std::string openPrompt; @@ -3182,6 +3298,80 @@ void applyHit(const PanelHit& hit, PanelModel& model, EyePanel& panel, Autostart syncEyecamControl(model.eyecam, *eyecamControl); return; } + case PanelAction::ContribClose: + case PanelAction::ContribExtra: + case PanelAction::ContribDelete: + case PanelAction::ContribDeleteCancel: return; // handled inside the panel + case PanelAction::ContribOpen: + // Read from the disk the first time (the loop reads it at its start; it follows it from then on) + if (contribSession != nullptr && !model.contrib.loaded) { + contribSession->load(model.contrib, contrib::defaultHome()); + } + std::fprintf(stderr, "[contrib] opened: %s\n", + model.contrib.dir.empty() ? "none yet" : recorder::shortPath(model.contrib.dir).c_str()); + return; + case PanelAction::ContribConsent: + if (contribSession == nullptr) { + std::printf("contrib: would make a contribution's folder in %s\n", contrib::defaultHome().c_str()); + return; + } + if (contribSession->consent(model.contrib, FRAMEEYEOSC_VERSION, std::time(nullptr))) { + std::fprintf(stderr, "[contrib] consent given: %s\n", recorder::shortPath(model.contrib.dir).c_str()); + } else { + std::fprintf(stderr, "[contrib] couldn't begin: %s\n", model.contrib.error.c_str()); + } + return; + case PanelAction::ContribRewore: + if (contribSession != nullptr && contribSession->rewore(model.contrib)) { + std::fprintf(stderr, "[contrib] re-wear: the user said so\n"); + } + return; + case PanelAction::ContribStart: { + // The step's protocol to eyecam-rec's socket (its reply and its run are followed by the loop) + if (contribSession == nullptr || eyecamControl == nullptr) { + std::printf("contrib: would start step %d (%s)\n", hit.arg + 1, contrib::protocolOf(hit.arg)); + return; + } + const std::string command = contribSession->startCommand(model.contrib, model.eyecam, hit.arg); + if (command.empty()) { + std::fprintf(stderr, "[contrib] step %d can't start now\n", hit.arg + 1); + return; + } + const std::string socket = model.eyecamDir + "/ctl.sock"; + std::fprintf(stderr, "[contrib] step %d: sending \"%s\" to %s\n", hit.arg + 1, command.c_str(), + socket.c_str()); + const bool sent = eyecamControl->send(socket, command, nowSeconds()); + contribSession->started(model.contrib, hit.arg, command, unixNow()); + if (!sent && !eyecamControl->busy()) { + std::fprintf(stderr, "[contrib] %s failed: %s\n", command.c_str(), + eyecamControl->reply().error.c_str()); + contribSession->replied(model.contrib, eyecamControl->reply()); + } + syncEyecamControl(model.eyecam, *eyecamControl); + return; + } + case PanelAction::ContribPack: + if (contribSession == nullptr) { + std::printf("contrib: would pack %s\n", model.contrib.dir.c_str()); + return; + } + if (contribSession->pack(model.contrib)) { + std::fprintf(stderr, "[contrib] packing %s\n", recorder::shortPath(model.contrib.dir).c_str()); + } else { + std::fprintf(stderr, "[contrib] can't pack: %s\n", model.contrib.progress.error.c_str()); + } + return; + case PanelAction::ContribDeleteConfirm: + if (contribSession == nullptr) { + std::printf("contrib: would delete %s\n", model.contrib.dir.c_str()); + return; + } + if (contribSession->remove(model.contrib)) { + std::fprintf(stderr, "[contrib] deleting %s\n", recorder::shortPath(model.contrib.dir).c_str()); + } else { + std::fprintf(stderr, "[contrib] can't delete %s\n", model.contrib.dir.c_str()); + } + return; case PanelAction::FitStop: std::fprintf(stderr, "[fit] stopped\n"); if (fit != nullptr) fit->cancel(); @@ -3651,6 +3841,14 @@ int runOverlay(const Options& options) { eyecam::Light light; // the light as shown: which one, and its alpha as it fades in and out eyecam::Fill lightWanted = eyecam::Fill::None; // the one fillFor wanted last time (to log a fade-out once) bool lightMoving = false; // fading in or out: the loop runs every display frame + // "Help train the AI": the contribution in ~/eyecam (a recording it had started goes on being followed) + contrib::Session contribSession; + contribSession.load(model.contrib, contrib::defaultHome()); + if (!model.contrib.dir.empty()) { + std::fprintf(stderr, "[contrib] %s: %d of %d recorded%s\n", recorder::shortPath(model.contrib.dir).c_str(), + contrib::requiredDone(model.contrib.info), contrib::kRequiredSteps, + model.contrib.running >= 0 ? ", one recording on its way" : ""); + } // The debug gaze dots: their socket, the three dot images and the three ring images after them (drawn once), which // image each overlay has (0 and 1 the dots, 2 and 3 the rings: the gaze before the user's gaze scale) gaze_dots::Receiver dots; @@ -3799,7 +3997,8 @@ int runOverlay(const Options& options) { std::fprintf(stderr, "[VR] quitting from the panel's \"Quit\"\n"); userQuit = true; } else { - applyHit(hit, model, panel, autostart, &updater, &fit, &vr, &eyeLog, &eyecamControl); + applyHit(hit, model, panel, autostart, &updater, &fit, &vr, &eyeLog, &eyecamControl, + &contribSession); lastStamp = configStamp(model.configPath); // The other apps' list opened: looked at now, and fetched if it is due if (hit.action == PanelAction::AppsOpen) { @@ -4318,12 +4517,14 @@ int runOverlay(const Options& options) { // with the other overlays at shutdown) { const bool running = model.eyecam.visible || vr.fillShown() || eyecamControl.busy(); + bool statusRead = false; if (nowSeconds() >= lastEyecamRead + (running ? kEyecamReadSec : kEyecamIdleReadSec)) { lastEyecamRead = nowSeconds(); model.eyecam.status = eyecam::readStatus(model.eyecamDir); // (the recording's steps for its chips, read from eyecam's protocol file once a run) eyecam::followProtocol(model.eyecam, eyecam::libDir()); model.eyecam.lastRun = eyecam::followRun(model.eyecam.lastRun, model.eyecam.status); + statusRead = true; } const double now = unixNow(); const eyecam::Status& s = model.eyecam.status; @@ -4344,6 +4545,12 @@ int runOverlay(const Options& options) { if (calibRun.active() && r.command == calibCommand && calibWatch.replied(r.ok)) { finishCalib(r.error.empty() ? std::string("err") : r.error); } + // A step of "Help train the AI" it refused: that try ends + if (contribSession.replied(model.contrib, r)) { + std::fprintf(stderr, "[contrib] step %d refused: %s\n", model.contrib.failure.step + 1, + r.error.c_str()); + dirty = true; + } // A sensitivity it refused: the slider shows the file's value again (the reply shows like others) if (!r.ok && eyecam::isSensitivityCommand(r.command)) { panel.dropSensitivityHold(); @@ -4383,6 +4590,17 @@ int runOverlay(const Options& options) { } } syncEyecamControl(model.eyecam, eyecamControl); + // "Help train the AI": its recording's end (the folder moved into the contribution), the re-wear before the + // next step (watched with the dashboard closed too), and the pack or the delete + if (statusRead) { + std::string log; + if (contribSession.follow(model.contrib, model.eyecam, now, bootSeconds(), log)) dirty = true; + for (size_t start = 0; start < log.size();) { + const size_t end = std::min(log.find('\n', start), log.size()); + std::fprintf(stderr, "[contrib] %s\n", log.substr(start, end - start).c_str()); + start = end + 1; + } + } // The calibration's record: where eyecam keeps it, the cameras' rate during it, and its end once eyecam-rec // leaves "calibrating" (or never got there) if (calibRun.active()) { diff --git a/panel/src/model.h b/panel/src/model.h index 1fa3c07..be50d76 100644 --- a/panel/src/model.h +++ b/panel/src/model.h @@ -5,6 +5,7 @@ #include "autostart.h" #include "changelog.h" #include "config.h" +#include "contrib.h" #include "diag.h" #include "eyecam.h" #include "eye_preview.h" @@ -59,6 +60,7 @@ struct PanelModel { RecordsView records; ///< the records (the Advanced tab's "Having trouble", and the failure screens' bar) AppsView apps; ///< the other Frame apps (the row at the bottom of "Version" and the list it opens) eye_preview::View preview; ///< the eye preview's values and pictures (only while its sheet is open) + contrib::View contrib; ///< "Help train the AI" (Debug tools; its sheet on the Advanced tab) std::string moduleLinkError; ///< why the Output tab's module link didn't open the browser ("" = none; its own ///< line under the link, never the eye cameras' setup's eyecam.spawnError) }; diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index 5aaeff6..cf3d819 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -637,6 +637,29 @@ std::string formatText(const char* format, const std::string& value) { return text; } +/** + * A printf into a std::string, for a longer text with one or two strings. + * @param format the format (one or two string arguments) + * @param first the first argument + * @param second the second (ignored by a format with one) + * @return the text + */ +std::string formatLong(const char* format, const std::string& first, const std::string& second = "") { + char text[1024]; + std::snprintf(text, sizeof(text), format, first.c_str(), second.c_str()); + return text; +} + +/** + * A step of "Help train the AI" as its recording's section title. + * @param t texts + * @param step the step + * @return "Help train the AI: Guided 2" + */ +std::string contribRunTitle(const UiText& t, int step) { + return formatLong(t.contribRunFormat, t.contribChips[std::clamp(step, 0, contrib::kStepCount - 1)]); +} + std::string cameraUseText(const UiText& t, CameraUse use, const EyeStatus& s) { std::string text; switch (use) { @@ -907,6 +930,7 @@ PanelHit EyePanel::pointerDown(double x, double y, double now) { case PanelAction::Tab: setTab(static_cast(hit.arg)); closeAppsList(); + closeContrib(); historyOpen_ = false; diagOpen_ = false; recordOpen_.clear(); @@ -918,6 +942,7 @@ PanelHit EyePanel::pointerDown(double x, double y, double now) { setTab(PanelTab::Advanced); setAdvPage(static_cast(hit.arg)); closeAppsList(); + closeContrib(); historyOpen_ = false; diagOpen_ = false; recordOpen_.clear(); @@ -929,6 +954,7 @@ PanelHit EyePanel::pointerDown(double x, double y, double now) { setTab(PanelTab::Lids); setLidsPage(static_cast(hit.arg)); closeAppsList(); + closeContrib(); historyOpen_ = false; diagOpen_ = false; recordOpen_.clear(); @@ -939,6 +965,7 @@ PanelHit EyePanel::pointerDown(double x, double y, double now) { setTab(PanelTab::Gaze); setGazePage(static_cast(hit.arg)); closeAppsList(); + closeContrib(); return {}; case PanelAction::GazeScaleLink: // Opened or closed here; the caller writes the second value once it is closed @@ -1046,6 +1073,7 @@ PanelHit EyePanel::pointerDown(double x, double y, double now) { // Its steps show on the eye cameras tab if (eyecamTab_) tab_ = PanelTab::Eyecam; closeAppsList(); + closeContrib(); historyOpen_ = false; diagOpen_ = false; recordOpen_.clear(); @@ -1064,6 +1092,28 @@ PanelHit EyePanel::pointerDown(double x, double y, double now) { numKey_ = hit.key; numValue_ = numberAt(numKey_, x); return {}; + case PanelAction::ContribOpen: + // The caller reads the contribution again + openContrib(); + return hit; + case PanelAction::ContribClose: + closeContrib(); + return {}; + case PanelAction::ContribExtra: + contribExtra_ = hit.arg != 0; + return {}; + case PanelAction::ContribDelete: + contribConfirm_ = true; + return {}; + case PanelAction::ContribDeleteCancel: + contribConfirm_ = false; + return {}; + case PanelAction::ContribDeleteConfirm: + // The caller deletes it; the sheet shows the deleting, then its first screen + if (!contribConfirm_) return {}; + contribConfirm_ = false; + contribExtra_ = false; + return hit; case PanelAction::EyecamChoose: if (!eyecamConfirm_.isOpen()) return {}; // "Cancel" only closes it @@ -1179,6 +1229,7 @@ void EyePanel::openHistory() { recordOpen_.clear(); recordsAllOpen_ = false; closeAppsList(); + closeContrib(); historyOpen_ = true; historyRowPending_ = true; historyScrollSet_ = false; @@ -1193,6 +1244,7 @@ void EyePanel::openDiag() { diagOpen_ = true; historyOpen_ = false; closeAppsList(); + closeContrib(); recordOpen_.clear(); recordsAllOpen_ = false; eyecamConfirm_.close(); @@ -1206,6 +1258,7 @@ void EyePanel::openRecord(const std::string& folder) { recordOpen_ = folder; historyOpen_ = false; closeAppsList(); + closeContrib(); diagOpen_ = false; eyecamConfirm_.close(); } @@ -1218,8 +1271,24 @@ void EyePanel::openRecordsAll() { listScroll_ = 0.0; historyOpen_ = false; closeAppsList(); + closeContrib(); + diagOpen_ = false; + eyecamConfirm_.close(); +} + +void EyePanel::openContrib() { + setTab(PanelTab::Advanced); + // (from "Debug tools": its cross comes back to it) + setAdvPage(AdvPage::Tools); + historyOpen_ = false; diagOpen_ = false; + recordOpen_.clear(); + recordsAllOpen_ = false; eyecamConfirm_.close(); + closeAppsList(); + contribOpen_ = true; + contribConfirm_ = false; + contribExtra_ = false; } void EyePanel::openAppsList() { @@ -1231,6 +1300,7 @@ void EyePanel::openAppsList() { recordOpen_.clear(); recordsAllOpen_ = false; eyecamConfirm_.close(); + closeContrib(); appsListOpen_ = true; appsConfirmOpen_ = false; appsLaunchResult_ = frame_apps::LaunchResult::Started; @@ -3676,11 +3746,22 @@ void EyePanel::drawAdvanced(const Pen& pen, const UiText& t, const PanelModel& m } return; } - // The developer recording: its light warning, and its steps while it runs, in the tab's place + // "Help train the AI": its sheet in the tab's place (its recordings run in it) + if (contribOpen_) { + drawContrib(pen, t, m); + return; + } + // The developer recording: its light warning, and its steps while it runs, in the tab's place (a step of "Help + // train the AI" with its sheet closed: titled with that step) const eyecam::State recording = m.eyecam.status.state; if (eyecamConfirm_.isOpen() || (m.eyecam.visible && (recording == eyecam::State::Searching || recording == eyecam::State::Recording))) { - drawRun(pen, t, m); + const int step = m.contrib.running; + if (step >= 0 && step < contrib::kStepCount) { + drawRun(pen, t, m, contribRunTitle(t, step), contrib::isFree(step) ? t.contribFreeRunning : nullptr); + } else { + drawRun(pen, t, m); + } return; } // The sub-tabs, and under them the one chosen (scrolled only if it doesn't fit) @@ -4565,10 +4646,47 @@ double EyePanel::drawToolsPage(const Pen& pen, const UiText& t, const PanelModel } } y += h; + // Under it, "Help train the AI": recordings for the developer (usable once the cameras are set up) + y += 10; + y += drawContribRow(pen, t, m, y, right); } return y + 8 - top; } +double EyePanel::drawContribRow(const Pen& pen, const UiText& t, const PanelModel& m, double y, double right) { + const contrib::View& c = m.contrib; + const double h = 64; + const double bh = 46; + const bool setUp = eyecam::setUp(m.eyecam); + drawRowLabel(pen, t, y, h, t.contribRow, t.contribRowHint, false); + const double bw = 160; + drawButton(pen, kControlX, y + (h - bh) / 2, bw, bh, t.contribOpen, {PanelAction::ContribOpen, nullptr, 0}, setUp, + false); + // Beside it: what it is (and its size), how far it is, or why it can't be used yet + std::string text; + if (!setUp) { + text = t.contribRowNotSetUp; + } else if (c.dir.empty()) { + text = formatLong(t.contribRowIntroFormat, + contrib::sizeText(contrib::neededBytes(contrib::Info(), c.fps) - contrib::kMarginBytes)); + } else if (contrib::requiredDone(c.info) >= contrib::kRequiredSteps) { + text = t.contribRowDone; + } else { + char line[256]; + std::snprintf(line, sizeof(line), t.contribRowProgressFormat, contrib::requiredDone(c.info), + contrib::kRequiredSteps); + text = line; + } + const double textX = kControlX + bw + 14; + const std::vector lines = wrapText(pen, text, 13, false, right - textX, 3); + double baseline = y + h / 2 - (lines.size() - 1) * 8.5 + 5; + for (const std::string& line : lines) { + pen.text(textX, baseline, line, 13, kTextMuted); + baseline += 17; + } + return h; +} + double EyePanel::drawFilesPage(const Pen& pen, const UiText& t, const PanelModel& m, double top, double right) { const EyeStatus& s = m.status; const double controlW = right - kControlX; @@ -5377,7 +5495,8 @@ void EyePanel::drawHostEntry(const Pen& pen, const UiText& t) { } } -void EyePanel::drawRun(const Pen& pen, const UiText& t, const PanelModel& m) { +void EyePanel::drawRun(const Pen& pen, const UiText& t, const PanelModel& m, const std::string& title, + const char* instruction) { using eyecam::State; const eyecam::View& view = m.eyecam; const eyecam::Status& s = view.status; @@ -5420,7 +5539,7 @@ void EyePanel::drawRun(const Pen& pen, const UiText& t, const PanelModel& m) { const eyecam::Calib calib = view.lastRun == eyecam::Run::CalibUser ? eyecam::Calib::User : eyecam::Calib::Wear; double y = kRowTop - 6; - y += drawSectionTitle(pen, y, calibRun ? t.tabEyecam : t.eyecamTitle); + y += drawSectionTitle(pen, y, !title.empty() ? title : calibRun ? t.tabEyecam : t.eyecamTitle); // The light warning takes the place of idle's and error's views while it is open (the recorder's message too: // it is about the run before) @@ -5488,13 +5607,15 @@ void EyePanel::drawRun(const Pen& pen, const UiText& t, const PanelModel& m) { true); // A recording's steps under it, as chips (as many as fit around the one now; none when eyecam's protocol // file can't be read), and everything below that lower down - const eyecam::StepChips chips = calibrating ? eyecam::StepChips() : eyecam::recordingChips(view); + const eyecam::StepChips chips = + calibrating || instruction != nullptr ? eyecam::StepChips() : eyecam::recordingChips(view); if (!chips.labels.empty()) { drawRunChips(pen, t, chips, y + 66, width); y += 40; } // The instruction, as large as it fits - centered(y + 150, eyecam::instruction(t, s.stepLabel), 96, 36, kText, true); + centered(y + 150, instruction != nullptr ? std::string(instruction) : eyecam::instruction(t, s.stepLabel), + 96, 36, kText, true); // The seconds left of the step; while the cameras lost the eyes (the headset came off; the time runs on), // a call to put it back on in their place if (!s.locked) { @@ -6907,6 +7028,362 @@ void EyePanel::drawEyecamConfirm(const Pen& pen, const UiText& t, const eyecam:: {PanelAction::EyecamChoose, nullptr, static_cast(StartChoice::Cancel)}, true, false, 24); } +void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) { + using contrib::Screen; + const contrib::View& c = m.contrib; + const Screen screen = contrib::screen(c, contribExtra_); + // Its recording: the eye capture's run, titled with the step (its "Stop" as always); until eyecam-rec shows it + // running (or after it ended, until the loop has followed that), only that it is on its way + if (screen == Screen::Running) { + const eyecam::State state = m.eyecam.status.state; + if (state == eyecam::State::Searching || state == eyecam::State::Recording) { + drawRun(pen, t, m, contribRunTitle(t, c.running), + contrib::isFree(c.running) ? t.contribFreeRunning : nullptr); + } else { + drawSectionTitle(pen, kRowTop - 6, contribRunTitle(t, c.running)); + textCentered(pen, (kInnerX + kInnerRight) / 2, kContentY + kContentH / 2, t.eyecamSending, 28, kTextMuted, + true); + } + return; + } + const int step = contrib::nextStep(c.info, contribExtra_); + const double width = kInnerRight - kInnerX; + const double bottom = kContentY + kContentH - 20; + const bool jobRunning = c.progress.running; + + // The title and the cross; on a step's screens "Delete these recordings" beside the cross + const double closeS = 52; + const double closeX = kInnerRight - closeS; + const double headY = kContentY + 20; + drawSheetClose(pen, {PanelAction::ContribClose, nullptr, 0}, closeX, headY + 2, closeS); + double titleRight = closeX - 16; + if (screen == Screen::Rewear || screen == Screen::Ready || screen == Screen::Failed) { + const double dw = pen.measure(t.contribDelete, 15, true) + 36; + titleRight -= dw + 12; + drawButton(pen, closeX - 12 - dw, headY + 8, dw, 40, t.contribDelete, {PanelAction::ContribDelete, nullptr, 0}, + !jobRunning, false, 15); + } + pen.text(kInnerX, headY + 26, t.contribRow, fitSize(pen, t.contribRow, 22, 16, titleRight - kInnerX, true), kText, + true); + pen.text(kInnerX, headY + 50, ellipsize(pen, t.contribRowHint, 14, false, titleRight - kInnerX, false), 14, + kTextMuted); + if (contribConfirm_) { + drawContribDelete(pen, t, m); + return; + } + if (screen == Screen::Deleting) { + textCentered(pen, (kInnerX + kInnerRight) / 2, kContentY + kContentH / 2, t.contribDeleting, 28, kText, true); + return; + } + + // Lines from the top down: wrapped, as many as fit in maxLines + double y = headY + 70; + const auto paragraph = [&](const std::string& text, double size, Color color, bool bold, size_t maxLines, + double x = kInnerX) { + for (const std::string& line : wrapText(pen, text, size, bold, kInnerRight - x, maxLines)) { + pen.text(x, y + size, line, size, color, bold); + y += size * 1.4; + } + }; + // The steps as chips (done: a check, the next one: the accent), the optional one once it is asked for or done + if (screen != Screen::Consent) { + eyecam::StepChips chips; + const bool extraShown = step == contrib::kRequiredSteps || !c.info.steps[contrib::kRequiredSteps].rec.empty(); + const int shown = extraShown ? contrib::kStepCount : contrib::kRequiredSteps; + for (int i = 0; i < shown; ++i) chips.labels.push_back(t.contribChips[i]); + chips.current = screen == Screen::Done ? shown : step; + drawRunChips(pen, t, chips, y + 26, width); + y += 52; + } + // A failure on the disk (info.txt, a folder), above everything else + if (!c.error.empty()) paragraph(formatLong(t.contribErrorFormat, c.error), 15, kDanger, true, 2); + + // A step's title (and, after its re-wear, that it is done) + const auto stepTitle = [&]() { + std::string title; + if (contrib::isFree(step)) { + title = step >= contrib::kRequiredSteps ? t.contribFreeExtraTitle : t.contribFreeTitle; + } else { + char text[128]; + std::snprintf(text, sizeof(text), t.contribGuidedFormat, step + 1, 3); + title = text; + } + const double titleW = pen.text(kInnerX, y + 24, title, 24, kText, true); + if (screen == Screen::Ready && contrib::needsRewear(step)) { + const double x = kInnerX + titleW + 24; + drawCheck(pen.cr, x + 8, y + 16, 16, kSuccess); + pen.text(x + 24, y + 22, ellipsize(pen, t.contribWearDone, 16, true, kInnerRight - x - 24, false), 16, + kSuccess, true); + } + y += 44; + }; + // At the bottom right: the screen's main button + const double mainH = 60; + const double mainY = bottom - mainH; + const auto mainButton = [&](const std::string& label, const PanelHit& hit, bool usable, bool accent) { + const double w = std::max(300.0, pen.measure(label, 22, true) + 64); + drawButton(pen, kInnerRight - w, mainY, w, mainH, label, hit, usable, accent, 22); + return kInnerRight - w; + }; + // Above it: why it can't be pressed (no room, eyecam-rec busy) + const auto whyNot = [&](int forStep) { + if (!contrib::fits(forStep, c.freeBytes, c.fps)) { + const long long need = contrib::estimateBytes(forStep, c.fps) + contrib::kMarginBytes; + pen.text(kInnerX, mainY - 16, formatLong(t.contribNoSpaceFormat, contrib::sizeText(need)), 16, kDanger, + true); + } else if (!contrib::startAllowed(c, m.eyecam, forStep)) { + pen.text(kInnerX, mainY - 16, ellipsize(pen, t.contribWaitEyecam, 16, false, width, false), 16, + kTextMuted); + } + }; + + switch (screen) { + case Screen::Consent: { + if (c.deleted) { + drawCheck(pen.cr, kInnerX + 9, y + 10, 16, kSuccess); + pen.text(kInnerX + 26, y + 16, t.contribDeleted, 16, kSuccess, true); + y += 30; + } + paragraph(t.contribLead, 18, kText, false, 2); + y += 10; + // What, how it's used, removing it: a title at the left, its text beside it + const long long all = contrib::neededBytes(contrib::Info(), c.fps) - contrib::kMarginBytes; + const std::string blocks[3][2] = { + {t.contribWhatTitle, formatLong(t.contribWhatFormat, contrib::sizeText(all))}, + {t.contribUseTitle, t.contribUse}, + {t.contribRemoveTitle, t.contribRemove}, + }; + double labelW = 0; + for (const auto& block : blocks) labelW = std::max(labelW, pen.measure(block[0], 16, true)); + const double textX = kInnerX + labelW + 24; + for (const auto& block : blocks) { + fillRounded(pen, kInnerX - 2, y + 4, 4, 18, 2, kAccent); + pen.text(kInnerX + 10, y + 18, block[0], 16, kText, true); + const double top = y; + paragraph(block[1], 16, kText, false, 3, textX); + y = std::max(y, top + 26) + 8; + } + y += 4; + paragraph(t.contribNote, 15, kTextMuted, false, 2); + // The free space against what it needs + const long long need = contrib::neededBytes(c.info, c.fps); + const bool enough = c.freeBytes < 0 || c.freeBytes >= need; + if (c.freeBytes >= 0) { + const std::string line = + enough ? formatLong(t.contribSpaceFormat, contrib::sizeText(c.freeBytes), contrib::sizeText(need)) + : formatLong(t.contribSpaceShortFormat, contrib::sizeText(need - c.freeBytes)); + pen.text(kInnerX, mainY - 18, ellipsize(pen, line, 17, !enough, width, false), 17, + enough ? kTextMuted : kDanger, !enough); + } + const double agreeX = mainButton(t.contribAgree, {PanelAction::ContribConsent, nullptr, 0}, + enough && eyecam::setUp(m.eyecam), true); + const double cancelW = std::max(140.0, pen.measure(t.contribCancel, 20, true) + 48); + drawButton(pen, agreeX - 14 - cancelW, mainY, cancelW, mainH, t.contribCancel, + {PanelAction::ContribClose, nullptr, 0}, true, false, 20); + break; + } + case Screen::Rewear: { + stepTitle(); + // The ask, in a box: large, why, and what was seen so far + const double pad = 22; + const double boxTop = y; + const double innerW = width - pad * 2; + const std::vector ask = wrapText(pen, t.contribRewearTitle, 28, true, innerW, 2); + const std::vector why = wrapText(pen, t.contribRewearWhy, 16, false, innerW, 2); + const char* seen = c.offSeen ? t.contribRewearOff : t.contribRewearWaiting; + const std::vector state = wrapText(pen, seen, 17, c.offSeen, innerW - 26, 2); + const double boxH = pad * 2 + ask.size() * 38 + 6 + why.size() * 22 + 14 + state.size() * 24; + fillRounded(pen, kInnerX, boxTop, width, boxH, 16, kBg); + strokeRounded(pen, kInnerX, boxTop, width, boxH, 16, kAccent, 2); + double baseline = boxTop + pad + 28; + for (const std::string& line : ask) { + pen.text(kInnerX + pad, baseline, line, 28, kText, true); + baseline += 38; + } + baseline += 6 - 38 + 22; + for (const std::string& line : why) { + pen.text(kInnerX + pad, baseline, line, 16, kTextMuted); + baseline += 22; + } + baseline += 14 - 22 + 24; + drawDot(pen.cr, kInnerX + pad + 8, baseline - 6, 6, c.offSeen ? kAccent : kTextMuted); + for (const std::string& line : state) { + pen.text(kInnerX + pad + 26, baseline, line, 17, c.offSeen ? kText : kTextMuted, c.offSeen); + baseline += 24; + } + // "I put it back on" (a while after the step began waiting), with what it is for beside it + const double reworeX = mainButton(t.contribRewore, {PanelAction::ContribRewore, nullptr, 0}, + c.fallbackReady && !jobRunning, false); + const char* hint = c.fallbackReady ? t.contribReworeHint : t.contribReworeWait; + const std::vector lines = wrapText(pen, hint, 15, false, reworeX - 20 - kInnerX, 2); + double hintBaseline = mainY + mainH / 2 + 5 - (static_cast(lines.size()) - 1) * 10; + for (const std::string& line : lines) { + pen.text(kInnerX, hintBaseline, line, 15, kTextMuted); + hintBaseline += 20; + } + break; + } + case Screen::Ready: { + stepTitle(); + if (contrib::isFree(step)) { + for (const char* tip : t.contribFreeTips) { + drawDot(pen.cr, kInnerX + 7, y + 13, 4, kAccent); + paragraph(tip, 19, kText, false, 2, kInnerX + 22); + y += 4; + } + y += 4; + paragraph(t.contribFreeEnd, 16, kTextMuted, false, 1); + } else { + paragraph(t.contribGuidedNote, 20, kText, false, 3); + } + whyNot(step); + double startX = mainButton(contrib::isFree(step) ? t.contribFreeStart : t.contribGuidedStart, + {PanelAction::ContribStart, nullptr, step}, + contrib::startAllowed(c, m.eyecam, step), true); + // (the optional step: back to the end) + if (step >= contrib::kRequiredSteps) { + const double backW = std::max(140.0, pen.measure(t.contribBack, 20, true) + 48); + startX -= 14 + backW; + drawButton(pen, startX, mainY, backW, mainH, t.contribBack, {PanelAction::ContribExtra, nullptr, 0}, + true, false, 20); + } + break; + } + case Screen::Failed: { + stepTitle(); + paragraph(t.contribFailedTitle, 26, kDanger, true, 1); + y += 6; + const contrib::Failure& f = c.failure; + const std::string& message = m.language == Language::En && !f.messageEn.empty() ? f.messageEn : f.message; + std::string why; + switch (f.why) { + case contrib::Why::None: + case contrib::Why::Stopped: why = t.contribWhyStopped; break; + case contrib::Why::Error: why = formatLong(t.contribWhyErrorFormat, message); break; + case contrib::Why::Refused: why = formatLong(t.contribWhyRefusedFormat, message); break; + case contrib::Why::Gone: why = t.contribWhyGone; break; + case contrib::Why::NoStart: why = t.contribWhyNoStart; break; + case contrib::Why::Missing: why = t.contribWhyMissing; break; + case contrib::Why::Move: why = formatLong(t.contribWhyMoveFormat, message); break; + } + paragraph(why, 19, kText, false, 3); + if (!f.kept.empty()) { + y += 4; + paragraph(t.contribKept, 16, kTextMuted, false, 2); + } + whyNot(step); + mainButton(t.contribRetry, {PanelAction::ContribStart, nullptr, step}, + contrib::startAllowed(c, m.eyecam, step) && + (!contrib::needsRewear(step) || !c.info.nextWear.empty()), + true); + break; + } + case Screen::Done: { + drawCheck(pen.cr, kInnerX + 14, y + 16, 24, kSuccess); + pen.text(kInnerX + 38, y + 26, t.contribDoneTitle, + fitSize(pen, t.contribDoneTitle, 26, 18, width - 38, true), kText, true); + y += 46; + paragraph(formatLong(t.contribTotalFormat, contrib::sizeText(contrib::recordedBytes(c.info))), 17, kText, + true, 1); + pen.text(kInnerX, y + 15, + ellipsize(pen, formatLong(t.contribWhereFormat, recorder::shortPath(c.dir) + "/"), 15, false, + width, true), + 15, kTextMuted); + y += 30; + paragraph(t.contribNext, 18, kText, false, 2); + y += 10; + // "Make one file", and beside it how that stands + const contrib::Progress& p = c.progress; + const bool packing = p.running && p.job == contrib::Job::Pack; + const long long packNeed = contrib::packNeedBytes(c.info, c.compress); + const bool room = c.freeBytes < 0 || c.freeBytes >= packNeed; + const double packW = std::max(280.0, pen.measure(t.contribPack, 19, true) + 56); + const double packH = 54; + drawButton(pen, kInnerX, y, packW, packH, t.contribPack, {PanelAction::ContribPack, nullptr, 0}, + !jobRunning && room && c.info.packed.empty(), true, 19); + const double sx = kInnerX + packW + 18; + const double sw = kInnerRight - sx; + std::string state; + Color color = kTextMuted; + bool bold = false; + if (packing) { + const double done = p.total > 0 ? std::clamp(static_cast(p.done) / p.total, 0.0, 1.0) : 0.0; + char text[128]; + std::snprintf(text, sizeof(text), t.contribPackingFormat, static_cast(done * 100)); + state = text; + color = kText; + // (a bar under the line) + const double barY = y + packH - 16; + fillRounded(pen, sx, barY, sw, 12, 6, kControl); + if (done > 0) fillRounded(pen, sx, barY, std::max(12.0, sw * done), 12, 6, kAccent); + } else if (!c.info.packed.empty()) { + // (done, and under it the file's name, cut at its start if it must be) + const std::string done = formatLong(t.contribPackedFormat, contrib::sizeText(c.info.packedBytes)); + pen.text(sx, y + packH / 2 - 4, done, 16, kSuccess, true); + pen.text(sx, y + packH / 2 + 18, ellipsize(pen, c.info.packed, 15, false, sw, true), 15, kText); + } else if (p.job == contrib::Job::Pack && p.finished && !p.ok) { + state = formatLong(t.contribPackFailedFormat, p.error); + color = kDanger; + bold = true; + } else if (!room) { + state = formatLong(t.contribPackNoSpaceFormat, contrib::sizeText(packNeed)); + color = kDanger; + bold = true; + } else { + state = formatLong(t.contribPackHintFormat, contrib::sizeText(packNeed)); + } + const std::vector lines = wrapText(pen, state, 15, bold, sw, packing ? 1 : 2); + double baseline = packing ? y + 22 : y + packH / 2 + 5 - (static_cast(lines.size()) - 1) * 10; + for (const std::string& line : lines) { + pen.text(sx, baseline, line, 15, color, bold); + baseline += 20; + } + // At the bottom: deleting them (after sending), and the optional free use while it isn't done + const double deleteW = std::max(220.0, pen.measure(t.contribDelete, 18, true) + 48); + drawButton(pen, kInnerX, bottom - 52, deleteW, 52, t.contribDelete, + {PanelAction::ContribDelete, nullptr, 0}, !jobRunning, false, 18); + if (c.info.steps[contrib::kRequiredSteps].rec.empty()) { + const double extraW = std::max(300.0, pen.measure(t.contribExtra, 18, true) + 48); + drawButton(pen, kInnerRight - extraW, bottom - 52, extraW, 52, t.contribExtra, + {PanelAction::ContribExtra, nullptr, 1}, !jobRunning, false, 18); + } + break; + } + case Screen::Running: + case Screen::Deleting: break; + } +} + +void EyePanel::drawContribDelete(const Pen& pen, const UiText& t, const PanelModel& m) { + const contrib::View& c = m.contrib; + // A card in the middle, as the other confirmations + const double w = kRight - kRightX - 120; + const double x = kRightX + 60; + const double ix = x + 28; + const double innerW = w - 56; + const std::string body = formatLong(t.contribDeleteFormat, recorder::shortPath(c.dir) + "/", + contrib::sizeText(c.totalBytes)); + const std::vector lines = wrapText(pen, body, 17, false, innerW, 4); + const double h = 26 + 32 + 14 + lines.size() * 26.0 + 22 + kControlH + 26; + double y = kContentY + 80 + std::max(0.0, (kContentH - 80 - h) / 2); + drawCard(pen, x, y, w, h, 20, kCard, kDanger, 2); + y += 26; + pen.text(ix, y + 24, t.contribDeleteTitle, fitSize(pen, t.contribDeleteTitle, 24, 16, innerW, true), kText, true); + y += 32 + 14; + for (const std::string& line : lines) { + pen.text(ix, y + 17, line, 17, kText); + y += 26; + } + y += 22; + const double yesW = std::max(160.0, pen.measure(t.contribDeleteYes, 18, true) + 56); + const double yesX = ix + innerW - yesW; + drawButton(pen, yesX, y, yesW, kControlH, t.contribDeleteYes, {PanelAction::ContribDeleteConfirm, nullptr, 0}, + !c.progress.running && c.running < 0, false, 18); + const double noW = std::max(140.0, pen.measure(t.contribCancel, 18, true) + 48); + drawButton(pen, yesX - 12 - noW, y, noW, kControlH, t.contribCancel, {PanelAction::ContribDeleteCancel, nullptr, 0}, + true, true, 18); +} + void EyePanel::drawPreviewButton(const Pen& pen, const UiText& t, double x, double y, double w, double h, bool label) { const PanelHit hit {PanelAction::PreviewOpen, nullptr, 0}; const int pointer = pointerState(hit); diff --git a/panel/src/panel.h b/panel/src/panel.h index 3129997..3d825f6 100644 --- a/panel/src/panel.h +++ b/panel/src/panel.h @@ -129,6 +129,17 @@ enum class PanelAction { PreviewMirror, ///< show the eyes as in a mirror, or as seen from the front (handled inside) PreviewCamera, ///< the eye cameras' pictures on / off (handled inside; the caller follows previewPictures) PreviewFreeze, ///< stop the drawing where it is, or let it move again (handled inside) + ContribOpen, ///< Debug tools' "Help train the AI": open its sheet (the panel opens it; the caller reads the + ///< contribution again) + ContribClose, ///< its cross, or "Cancel" on its first screen (handled inside) + ContribConsent, ///< "I'll help": the caller makes the contribution's folder + ContribRewore, ///< "I put it back on": the caller counts the next step's re-wear as done + ContribStart, ///< start step arg's recording (the caller sends its start to eyecam-rec) + ContribExtra, ///< arg 1: show the optional free use's step, 0: back to the end (handled inside) + ContribPack, ///< "Make one file": the caller packs it on its worker + ContribDelete, ///< "Delete these recordings": its confirmation (handled inside) + ContribDeleteCancel, ///< ...closed without deleting (handled inside) + ContribDeleteConfirm, ///< ...confirmed: the caller deletes the contribution's folder }; /** A button: its action, the config key it changes and an argument. */ @@ -375,6 +386,34 @@ class EyePanel { /** @return true while the eye preview is open */ bool previewOpen() const { return previewOpen_; } + /** + * Open "Help train the AI" on the Advanced tab, in place of its page (the tabs and the sub-tabs' presses close it), + * as its row's button does. + */ + void openContrib(); + + /** Close it (and its confirmation). */ + void closeContrib() { + contribOpen_ = false; + contribConfirm_ = false; + contribExtra_ = false; + } + + /** @return true while it shows */ + bool contribOpen() const { return tab_ == PanelTab::Advanced && contribOpen_; } + + /** @return true while it shows the optional free use's step ("Record normal use once more") */ + bool contribExtra() const { return contribExtra_; } + + /** + * Show the optional free use's step, or the end again (for --fake-contrib). + * @param extra the optional step + */ + void setContribExtra(bool extra) { contribExtra_ = extra; } + + /** Open the confirmation before deleting it (for --fake-contrib). */ + void openContribDelete() { contribConfirm_ = true; } + /** @return true while it wants the eye cameras' pictures (open, with its "Camera picture" on) */ bool previewPictures() const { return previewOpen_ && previewCamera_; } @@ -446,7 +485,7 @@ class EyePanel { /** @return true while the Advanced tab shows its sub-tabs and a page of them (nothing open over them) */ bool advPageShown() const { return tab_ == PanelTab::Advanced && !historyOpen_ && !diagOpen_ && recordOpen_.empty() && !recordsAllOpen_ && - !appsListOpen_; + !appsListOpen_ && !contribOpen_; } /** @@ -539,6 +578,7 @@ class EyePanel { */ bool wantsScroll() const { if (tab_ != PanelTab::Advanced) return false; + if (contribOpen_) return false; if (appsListOpen_) return !appsConfirmOpen_ && appsScrollable_; if (historyOpen_) return true; if (diagOpen_ || !recordOpen_.empty()) return false; @@ -708,6 +748,9 @@ class EyePanel { bool previewCamera_ = false; ///< ...with the eye cameras' pictures (off whenever the cameras can't give them) bool previewCameraKept_ = false; ///< ...the pictures' switch as last pressed, for the next time it opens (this ///< run of the panel only; the cameras going away turns it off too) + bool contribOpen_ = false; ///< "Help train the AI" is shown on the Advanced tab + bool contribExtra_ = false; ///< ...on the optional free use's step + bool contribConfirm_ = false; ///< ...with the confirmation before deleting it bool previewFrozen_ = false; ///< ...stopped where it was bool previewFreezePending_ = false; ///< ...the values to keep are taken at the next draw eye_preview::View previewKept_; ///< ...the values and pictures it stopped at @@ -1355,8 +1398,41 @@ class EyePanel { * @param pen drawing tools * @param t texts * @param model the model (its eyecam view) + * @param title the section's title instead of the recording's ("" = its own; a step of "Help train the AI") + * @param instruction a recording's instruction instead of its step's, without the step chips (nullptr = its own; + * the free use) + */ + void drawRun(const Pen& pen, const UiText& t, const PanelModel& model, const std::string& title = "", + const char* instruction = nullptr); + + /** + * "Help train the AI" (contrib::screen): the consent, the re-wear, a step's start, its recording (drawRun, titled + * with the step), a try that didn't finish, the end (pack, the optional free use, delete), and the confirmation + * before deleting. The title and the cross at the top, the steps as chips under it. + * @param pen drawing tools + * @param t texts + * @param model the model (its contribution and eyecam view) + */ + void drawContrib(const Pen& pen, const UiText& t, const PanelModel& model); + + /** + * The confirmation before deleting the contribution, over its sheet. + * @param pen drawing tools + * @param t texts + * @param model the model + */ + void drawContribDelete(const Pen& pen, const UiText& t, const PanelModel& model); + + /** + * Debug tools' "Help train the AI" row: its button (only once the eye cameras are set up), and how far it is. + * @param pen drawing tools + * @param t texts + * @param model the model + * @param y the row's top + * @param right the page's right edge + * @return its height */ - void drawRun(const Pen& pen, const UiText& t, const PanelModel& model); + double drawContribRow(const Pen& pen, const UiText& t, const PanelModel& model, double y, double right); /** * The setup checklist: (1) a password, (2) the tool, (3) the eye movements, (4) done. Steps done in a row each diff --git a/panel/tests/contrib_test.cpp b/panel/tests/contrib_test.cpp new file mode 100644 index 0000000..ce6dce0 --- /dev/null +++ b/panel/tests/contrib_test.cpp @@ -0,0 +1,1033 @@ +// Tests for "Help train the AI" (contrib.cpp): the steps and their sizes (against eyecam's own protocol files), +// info.txt (written, read back, odd lines), the folder names, following a step's recording in eyecam-rec's status +// (done, stopped while searching or recording, failed, refused, never started, eyecam-rec gone, a panel restarted in +// the middle), the re-wear between steps (the eye video, the proximity sensor, a sensor that never reads worn, the +// Frame asleep, the fallback's delay), which screen shows and when a start can be pressed, the tar headers, and on real +// folders in a temporary one (never ~/eyecam): making a contribution, moving recordings into it, packing it (tar, and +// through zstd when it is there: unpacked again with the system's tar and compared) and deleting it, and a whole +// session from the consent to the end. Built with the panel as contrib-test; exits non-zero on failure. +#include "contrib.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int gFailures = 0; + +/** + * Record a failed check. + * @param ok the check + * @param what what was checked + * @param line where + */ +void check(bool ok, const char* what, int line) { + if (ok) return; + ++gFailures; + std::fprintf(stderr, "FAILED line %d: %s\n", line, what); +} + +#define CHECK(condition) check((condition), #condition, __LINE__) + +/** + * Compare two texts, printing both when they differ. + * @param got the text made + * @param expected the text wanted + * @param line where + */ +void same(const std::string& got, const std::string& expected, int line) { + if (got == expected) return; + ++gFailures; + std::fprintf(stderr, "FAILED line %d:\n got \"%s\"\n expected \"%s\"\n", line, got.c_str(), expected.c_str()); +} + +#define SAME(got, expected) same((got), (expected), __LINE__) + +using contrib::Outcome; +using contrib::Screen; +using eyecam::State; + +/** Unix time the tests pretend it is. */ +constexpr double kNow = 1'790'000'000.0; + +/** + * A folder for the test's own files, in $TMPDIR (else /tmp). + * @return its path + */ +std::string tempDir() { + std::string pattern = (std::filesystem::temp_directory_path() / "contrib-test-XXXXXX").string(); + const char* dir = ::mkdtemp(pattern.data()); + return dir != nullptr ? dir : "/tmp"; +} + +/** A folder from tempDir, removed with what is in it when this goes (also when a check fails on the way). */ +struct RemoveDir { + std::string path; ///< the folder + ~RemoveDir() { + std::error_code error; + std::filesystem::remove_all(path, error); + } +}; + +/** + * Write a file. + * @param path where + * @param text what + */ +void writeFile(const std::string& path, const std::string& text) { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << text; +} + +/** + * Read a file. + * @param path where + * @return its text ("" if missing) + */ +std::string readFile(const std::string& path) { + std::ifstream in(path, std::ios::binary); + std::ostringstream text; + text << in.rdbuf(); + return text.str(); +} + +/** + * A status of eyecam-rec, fresh at a time. + * @param state its state + * @param protocol its protocol + * @param now when it was written + * @return the status + */ +eyecam::Status status(State state, const std::string& protocol, double now) { + eyecam::Status s; + s.present = true; + s.state = state; + s.stateText = state == State::Idle ? "idle" + : state == State::Searching ? "searching" + : state == State::Recording ? "recording" + : state == State::Error ? "error" + : "calibrating"; + s.protocol = protocol; + s.mtime = now; + s.prox = -1.0; + s.proxMin = 20.0; + s.live = true; + s.locked = true; + s.camFps[0] = s.camFps[1] = NAN; + return s; +} + +/** + * eyecam-rec set up and idle, as the panel would have it. + * @param now when its status was written + * @return the view + */ +eyecam::View eyecamReady(double now) { + eyecam::View v; + v.status = status(State::Idle, "", now); + v.status.hasSetupDone = v.status.setupDone = true; + v.status.hasBuffers = true; + v.status.autoGrab = "ok"; + v.status.calibState = eyecam::kCalibWearBit; + v.visible = true; + v.password = eyecam::PasswordState::Set; + return v; +} + +/** + * The steps' sum of seconds in a protocol file, as eyecam reads it ("seconds label" lines, "#" comments). + * @param text the file + * @return the seconds (-1 for a line it doesn't read) + */ +double protocolSeconds(const std::string& text) { + std::istringstream lines(text); + std::string line; + double total = 0.0; + while (std::getline(lines, line)) { + const size_t hash = line.find('#'); + if (hash != std::string::npos) line.resize(hash); + std::istringstream words(line); + double seconds = 0.0; + std::string label; + if (!(words >> seconds)) { + if (line.find_first_not_of(" \t\r") != std::string::npos) return -1.0; + continue; + } + if (!(words >> label) || seconds <= 0) return -1.0; + total += seconds; + } + return total; +} + +void testSteps() { + using namespace contrib; + SAME(protocolOf(0), kGuidedProtocol); + SAME(protocolOf(2), kGuidedProtocol); + SAME(protocolOf(3), kFreeProtocol); + SAME(protocolOf(4), kFreeProtocol); + CHECK(!isFree(2) && isFree(3) && isFree(4)); + CHECK(!needsRewear(0) && needsRewear(1) && needsRewear(3) && needsRewear(4)); + // eyecam-rec's estimate: 60.5 s and 181.5 s of two 90 fps cameras at 160300 bytes a frame + CHECK(estimateBytes(0, 90) == static_cast(60.5 * 2 * 90 * 160300.0)); + CHECK(estimateBytes(3, 90) == static_cast(181.5 * 2 * 90 * 160300.0)); + CHECK(estimateBytes(0, NAN) == estimateBytes(0, 90)); + CHECK(estimateBytes(0, 0) == estimateBytes(0, 90)); + CHECK(estimateBytes(0, 120) > estimateBytes(0, 90)); + // About 1.75 GB a guided one, 5.2 GB the free use: 10.5 GB for the four + Info info; + const long long all = neededBytes(info, 90) - kMarginBytes; + CHECK(all > 10'400'000'000LL && all < 10'600'000'000LL); + SAME(sizeText(all), "10.5 GB"); + SAME(sizeText(523'000'000), "523 MB"); + // The order: the first not done; the optional one only when asked for, after the others + CHECK(nextStep(info, false) == 0); + CHECK(nextStep(info, true) == 0); + info.steps[0].rec = "rec_2026-10-25_14-02-41"; + info.steps[1].rec = "rec_2026-10-25_14-05-12"; + CHECK(nextStep(info, false) == 2); + CHECK(requiredDone(info) == 2); + CHECK(neededBytes(info, 90) == kMarginBytes + estimateBytes(2, 90) + estimateBytes(3, 90)); + info.steps[2].rec = "rec_2026-10-25_14-08-03"; + info.steps[3].rec = "rec_2026-10-25_14-11-40"; + CHECK(nextStep(info, false) == kStepCount); + CHECK(nextStep(info, true) == 4); + CHECK(requiredDone(info) == kRequiredSteps); + CHECK(neededBytes(info, 90) == kMarginBytes); + info.steps[4].rec = "rec_2026-10-25_14-20-00"; + CHECK(nextStep(info, true) == kStepCount); + // Sizes: what was recorded, what packing needs + for (Step& step : info.steps) step.bytes = 1'000'000'000; + CHECK(recordedBytes(info) == 5'000'000'000LL); + CHECK(packNeedBytes(info, true) == 3'750'000'000LL + kMarginBytes); + CHECK(packNeedBytes(info, false) == 5'000'000'000LL + kMarginBytes); + // A step fits with its estimate and the margin free (or the free space unknown) + CHECK(fits(0, estimateBytes(0, 90) + kMarginBytes, 90)); + CHECK(!fits(0, estimateBytes(0, 90) + kMarginBytes - 1, 90)); + CHECK(!fits(3, estimateBytes(0, 90) + kMarginBytes, 90)); + CHECK(fits(3, -1, 90)); +} + +void testProtocolFiles() { + using namespace contrib; + // The protocols as eyecam ships them (install.sh puts every protocol_*.txt beside eyecam-rec) + const std::string guided = readFile(std::string(EYECAM_SOURCE_DIR) + "/protocol_" + kGuidedProtocol + ".txt"); + const std::string free = readFile(std::string(EYECAM_SOURCE_DIR) + "/protocol_" + kFreeProtocol + ".txt"); + CHECK(!guided.empty() && !free.empty()); + CHECK(protocolSeconds(guided) == kGuidedSec); + CHECK(protocolSeconds(free) == kFreeSec); + // Its steps are what the panel says: close / normal / widen, then squint, up, down; no light + const std::vector steps = eyecam::parseProtocol(guided); + CHECK(steps.size() == 14); + for (const char* label : {"close", "normal", "widen", "squint", "look_up", "look_down"}) { + CHECK(std::find(steps.begin(), steps.end(), std::string(label)) != steps.end()); + } + CHECK(std::find(steps.begin(), steps.end(), "bright") == steps.end()); + CHECK(std::find(steps.begin(), steps.end(), "dark") == steps.end()); + CHECK(eyecam::parseProtocol(free) == std::vector {"normal"}); +} + +void testInfo() { + using namespace contrib; + Info info; + info.appVersion = "0.8.2-beta.2"; + info.consent = "2026-10-25 14:02:10 +0900"; + for (int step = 0; step < kStepCount; ++step) info.steps[step].protocol = protocolOf(step); + info.steps[0] = {kGuidedProtocol, "rec_2026-10-25_14-02-41", 1'745'000'000, kWearFirst, "2026-10-25 14:03:44"}; + info.steps[1] = {kGuidedProtocol, "rec_2026-10-25_14-05-12", 1'744'000'000, kWearButton, "2026-10-25 14:06:30"}; + info.nextWear = kWearDetected; + info.running = 2; + info.runningSince = 1'790'000'123.25; + info.aborted = {"aborted/rec_2026-10-25_14-04-00 stopped"}; + const std::string text = formatInfo(info); + // No personal data: the comment, the version, times, protocols, folders and sizes only + CHECK(text.rfind("# frameeyeosc", 0) == 0); + CHECK(text.find("app_version=0.8.2-beta.2\n") != std::string::npos); + CHECK(text.find("step1_rec=rec_2026-10-25_14-02-41\n") != std::string::npos); + CHECK(text.find("step2_wear=button\n") != std::string::npos); + CHECK(text.find("step4_protocol=free\n") != std::string::npos); + CHECK(text.find("step3_rec=") == std::string::npos); + CHECK(text.find("running=3\n") != std::string::npos); + Info back; + CHECK(parseInfo(text, back)); + SAME(back.appVersion, info.appVersion); + SAME(back.consent, info.consent); + for (int step = 0; step < kStepCount; ++step) { + SAME(back.steps[step].protocol, protocolOf(step)); + SAME(back.steps[step].rec, info.steps[step].rec); + CHECK(back.steps[step].bytes == info.steps[step].bytes); + SAME(back.steps[step].wear, info.steps[step].wear); + SAME(back.steps[step].done, info.steps[step].done); + } + SAME(back.nextWear, kWearDetected); + CHECK(back.running == 2); + CHECK(std::fabs(back.runningSince - info.runningSince) < 1e-3); + CHECK(back.aborted == info.aborted); + CHECK(back.packed.empty()); + // Packed, nothing running + info.running = -1; + info.packed = "contrib_2026-10-25_14-02-10.tar.zst"; + info.packedBytes = 2'100'000'000; + CHECK(parseInfo(formatInfo(info), back)); + CHECK(back.running == -1); + SAME(back.packed, info.packed); + CHECK(back.packedBytes == info.packedBytes); + // Odd lines are skipped, CRLF read, a recording's name that isn't one dropped (that step is done again), steps + // past the fifth ignored + CHECK(parseInfo("consent=x\r\nnonsense\nstep1_rec=../../etc\nstep2_rec=rec_2026-10-25_14-05-12\r\n" + "step9_rec=rec_2026-10-25_14-05-12\nrunning=9\nstep2_bytes=12x\n", + back)); + CHECK(back.steps[0].rec.empty()); + SAME(back.steps[1].rec, "rec_2026-10-25_14-05-12"); + CHECK(back.steps[1].bytes == 0); + CHECK(back.running == -1); + // Without the consent it isn't a contribution's + CHECK(!parseInfo("app_version=1\n", back)); + CHECK(!parseInfo("", back)); +} + +void testNames() { + using namespace contrib; + CHECK(isFolderName("contrib_2026-10-25_14-02-10")); + CHECK(!isFolderName("contrib_2026-10-25_14-02-10.tar.zst")); + CHECK(!isFolderName("contrib_2026-10-25")); + CHECK(!isFolderName("rec_2026-10-25_14-02-10")); + CHECK(!isFolderName("contrib_../../x")); + CHECK(isRecordingName("rec_2026-10-25_14-02-10")); + CHECK(!isRecordingName("calib_2026-10-25_14-02-10")); + CHECK(!isRecordingName("rec_fake")); + CHECK(!isRecordingName("aborted/rec_2026-10-25_14-02-10")); + const std::string name = folderName(static_cast(kNow)); + CHECK(isFolderName(name)); + const std::string local = localTime(static_cast(kNow), true); + CHECK(local.size() == 25 && local[10] == ' ' && (local[20] == '+' || local[20] == '-')); + CHECK(localTime(static_cast(kNow), false).size() == 19); +} + +void testRunWatch() { + using contrib::RunWatch; + const std::string p = contrib::kGuidedProtocol; + const std::string dir = "/home/steamos/eyecam/rec_2026-10-25_14-05-12"; + // Searching, recording, then idle with its folder: done + { + RunWatch w; + w.begin(1, p, kNow); + CHECK(w.active() && w.step() == 1); + CHECK(w.follow(status(State::Searching, p, kNow + 0.1), true, kNow + 0.1, NAN).outcome == Outcome::Running); + CHECK(w.follow(status(State::Recording, p, kNow + 30), true, kNow + 30, NAN).outcome == Outcome::Running); + eyecam::Status s = status(State::Idle, p, kNow + 63); + s.sessionDir = dir; + s.message = "保存した: rec_2026-10-25_14-05-12"; + const contrib::RunEnd end = w.follow(s, true, kNow + 63, kNow + 2); + CHECK(end.outcome == Outcome::Done); + SAME(end.dir, dir); + } + // ...ended early (Stop, or the eyes lost): stopped, with the part's folder + { + RunWatch w; + w.begin(1, p, kNow); + w.follow(status(State::Recording, p, kNow + 10), true, kNow + 10, NAN); + eyecam::Status s = status(State::Idle, p, kNow + 20); + s.sessionDir = dir; + s.lastSessionAborted = true; + const contrib::RunEnd end = w.follow(s, true, kNow + 20, kNow + 2); + CHECK(end.outcome == Outcome::Stopped); + CHECK(end.why == contrib::Why::Stopped); + SAME(end.dir, dir); + } + // ...stopped while it searched: no folder (eyecam-rec cleared session_dir when it started) + { + RunWatch w; + w.begin(1, p, kNow); + w.follow(status(State::Searching, p, kNow + 1), true, kNow + 1, NAN); + const contrib::RunEnd end = w.follow(status(State::Idle, p, kNow + 5), true, kNow + 5, NAN); + CHECK(end.outcome == Outcome::Stopped); + CHECK(end.dir.empty()); + } + // ...failed: eyecam-rec's error, with what it saved + { + RunWatch w; + w.begin(1, p, kNow); + w.follow(status(State::Recording, p, kNow + 10), true, kNow + 10, NAN); + eyecam::Status s = status(State::Error, p, kNow + 20); + s.sessionDir = dir; + s.message = "ディスクの空きが 1 GB 未満(途中まで保存: rec_2026-10-25_14-05-12)"; + s.messageEn = "Less than 1 GB of disk space left (saved so far: rec_2026-10-25_14-05-12)"; + const contrib::RunEnd end = w.follow(s, true, kNow + 20, kNow + 2); + CHECK(end.outcome == Outcome::Failed); + CHECK(end.why == contrib::Why::Error); + SAME(end.dir, dir); + SAME(end.message, s.message); + SAME(end.messageEn, s.messageEn); + } + // An older status (idle, the previous recording's folder) right after the start: not this one's end + { + RunWatch w; + w.begin(2, p, kNow); + eyecam::Status s = status(State::Idle, p, kNow + 0.05); + s.sessionDir = "/home/steamos/eyecam/rec_2026-10-25_14-02-41"; + CHECK(w.follow(s, true, kNow + 0.05, kNow - 120).outcome == Outcome::Running); + // ...and never running at all: it didn't start + const contrib::RunEnd end = w.follow(s, true, kNow + contrib::kStartGraceSec, kNow - 120); + CHECK(end.outcome == Outcome::Failed); + CHECK(end.why == contrib::Why::NoStart); + CHECK(end.dir.empty()); + } + // A start that failed at once (error before the panel saw it searching): its message, after the grace + { + RunWatch w; + w.begin(0, p, kNow); + eyecam::Status s = status(State::Error, p, kNow + 0.2); + s.message = "ディスクの空きが 1 GB 未満"; + CHECK(w.follow(s, true, kNow + 0.2, NAN).outcome == Outcome::Running); + const contrib::RunEnd end = w.follow(s, true, kNow + 9, NAN); + CHECK(end.outcome == Outcome::Failed); + CHECK(end.why == contrib::Why::Error); + SAME(end.message, s.message); + } + // A calibration or another recording running: not this one (it never started) + { + RunWatch w; + w.begin(0, p, kNow); + CHECK(w.follow(status(State::Recording, "widen", kNow + 1), true, kNow + 1, NAN).outcome == Outcome::Running); + CHECK(w.follow(status(State::Calibrating, "calib wear", kNow + 9), true, kNow + 9, NAN).outcome == + Outcome::Failed); + } + // The panel restarted in the middle: it ended meanwhile, its folder made after the start: done + { + RunWatch w; + w.begin(3, contrib::kFreeProtocol, kNow); + eyecam::Status s = status(State::Idle, contrib::kFreeProtocol, kNow + 400); + s.sessionDir = dir; + CHECK(w.follow(s, true, kNow + 400, kNow + 3).outcome == Outcome::Done); + // ...but another protocol's folder isn't it + RunWatch other; + other.begin(3, contrib::kFreeProtocol, kNow); + eyecam::Status s2 = status(State::Idle, p, kNow + 400); + s2.sessionDir = dir; + CHECK(other.follow(s2, true, kNow + 400, kNow + 3).outcome == Outcome::Failed); + } + // ...its folder gone (moved by hand): missing + { + RunWatch w; + w.begin(1, p, kNow); + w.follow(status(State::Recording, p, kNow + 10), true, kNow + 10, NAN); + eyecam::Status s = status(State::Idle, p, kNow + 63); + s.sessionDir = dir; + const contrib::RunEnd end = w.follow(s, true, kNow + 63, NAN); + CHECK(end.outcome == Outcome::Failed); + CHECK(end.why == contrib::Why::Missing); + } + // eyecam-rec gone (its status stale) for kGoneSec: failed, not before + { + RunWatch w; + w.begin(1, p, kNow); + w.follow(status(State::Recording, p, kNow + 10), true, kNow + 10, NAN); + const eyecam::Status stale = status(State::Recording, p, kNow + 10); + CHECK(w.follow(stale, false, kNow + 16, NAN).outcome == Outcome::Running); + CHECK(w.follow(stale, false, kNow + 16 + contrib::kGoneSec - 1, NAN).outcome == Outcome::Running); + const contrib::RunEnd end = w.follow(stale, false, kNow + 16 + contrib::kGoneSec, NAN); + CHECK(end.outcome == Outcome::Failed); + CHECK(end.why == contrib::Why::Gone); + } + // Not following one: nothing + { + RunWatch w; + CHECK(!w.active()); + CHECK(w.follow(status(State::Idle, p, kNow), true, kNow, NAN).outcome == Outcome::Running); + } +} + +void testWearWatch() { + using contrib::WearWatch; + const auto idle = [](bool locked, double prox = -1.0) { + eyecam::Status s = status(State::Idle, "", kNow); + s.locked = locked; + s.prox = prox; + return s; + }; + // The eye video stops for kOffSec, then comes back for kOnSec + { + WearWatch w; + w.start(100); + double t = 100; + for (; t < 101; t += 0.1) w.follow(idle(true), true, t); + for (double end = t + 2.9; t < end; t += 0.1) w.follow(idle(false), true, t); + CHECK(!w.offSeen()); + for (double end = t + 0.3; t < end; t += 0.1) w.follow(idle(false), true, t); + CHECK(w.offSeen() && !w.detected()); + for (double end = t + 1.8; t < end; t += 0.1) w.follow(idle(true), true, t); + CHECK(!w.detected()); + for (double end = t + 0.5; t < end; t += 0.1) w.follow(idle(true), true, t); + CHECK(w.detected()); + } + // A short gap in the video (a blink of the tracker) is no re-wear + { + WearWatch w; + w.start(0); + double t = 0; + for (; t < 20; t += 0.1) w.follow(idle(t < 5 || t > 6.5), true, t); + CHECK(!w.offSeen() && !w.detected()); + } + // The sensor: off after it read worn, then worn again (live processing off: the video doesn't say) + { + WearWatch w; + w.start(0); + double t = 0; + const auto noLive = [&](double prox) { + eyecam::Status s = idle(false, prox); + s.live = false; + return s; + }; + for (; t < 2; t += 0.1) w.follow(noLive(31), true, t); + for (; t < 6; t += 0.1) w.follow(noLive(9), true, t); + CHECK(w.offSeen()); + for (; t < 9; t += 0.1) w.follow(noLive(31), true, t); + CHECK(w.detected()); + } + // A face the sensor never reads worn on: its low reading alone is no "off" + { + WearWatch w; + w.start(0); + for (double t = 0; t < 30; t += 0.1) w.follow(idle(true, 12), true, t); + CHECK(!w.offSeen()); + } + // The Frame slept (the loop stood still kOffSec or more): off; on again with the video + { + WearWatch w; + w.start(0); + w.follow(idle(true), true, 1); + w.follow(idle(true), true, 1 + contrib::kOffSec + 30); + CHECK(w.offSeen()); + w.follow(idle(true), true, 40); + w.follow(idle(true), true, 42.5); + CHECK(w.detected()); + } + // Back on without being off first: nothing + { + WearWatch w; + w.start(0); + for (double t = 0; t < 30; t += 0.1) w.follow(idle(true, 30), true, t); + CHECK(!w.offSeen() && !w.detected()); + } + // eyecam-rec not fresh: its numbers don't count + { + WearWatch w; + w.start(0); + for (double t = 0; t < 10; t += 0.1) w.follow(idle(false), false, t); + CHECK(!w.offSeen()); + } + // "I put it back on": kFallbackSec after the start, and only while watching + { + WearWatch w; + CHECK(!w.fallbackReady(1000)); + w.start(100); + CHECK(!w.fallbackReady(100 + contrib::kFallbackSec - 0.5)); + CHECK(w.fallbackReady(100 + contrib::kFallbackSec)); + w.stop(); + CHECK(!w.fallbackReady(1000)); + // Started again: the time again, and what it saw forgotten + w.start(500); + CHECK(!w.fallbackReady(505) && !w.offSeen()); + } +} + +void testScreen() { + using namespace contrib; + View v; + const eyecam::View ready = eyecamReady(kNow); + CHECK(screen(v, false) == Screen::Consent); + CHECK(!startAllowed(v, ready, 0)); + v.dir = "/home/steamos/eyecam/contrib_2026-10-25_14-02-10"; + v.freeBytes = 20'000'000'000LL; + CHECK(screen(v, false) == Screen::Ready); + CHECK(startAllowed(v, ready, 0)); + // eyecam-rec busy, not set up, a command waiting, no room: no start + { + eyecam::View busy = ready; + busy.status.state = State::Calibrating; + CHECK(!startAllowed(v, busy, 0)); + eyecam::View waiting = ready; + waiting.busy = true; + CHECK(!startAllowed(v, waiting, 0)); + eyecam::View notSetUp = ready; + notSetUp.status.setupDone = false; + notSetUp.status.calibState = 0; + CHECK(!startAllowed(v, notSetUp, 0)); + eyecam::View error = ready; + error.status.state = State::Error; + CHECK(startAllowed(v, error, 0)); + View full = v; + full.freeBytes = 2'000'000'000LL; + CHECK(!startAllowed(full, ready, 0)); + View packing = v; + packing.progress.running = true; + CHECK(!startAllowed(packing, ready, 0)); + } + // After the first: the re-wear, then the start + v.info.steps[0].rec = "rec_2026-10-25_14-02-41"; + CHECK(screen(v, false) == Screen::Rewear); + v.info.nextWear = kWearDetected; + CHECK(screen(v, false) == Screen::Ready); + // Running + v.running = 1; + CHECK(screen(v, false) == Screen::Running); + CHECK(!startAllowed(v, ready, 1)); + v.running = -1; + // A try that didn't finish: its screen, for its step only + v.failure.step = 1; + CHECK(screen(v, false) == Screen::Failed); + v.failure.step = 0; + CHECK(screen(v, false) == Screen::Ready); + // All four: the end; the optional one when asked for (its re-wear first) + for (int step = 0; step < kRequiredSteps; ++step) v.info.steps[step].rec = "rec_2026-10-25_14-02-41"; + v.info.nextWear.clear(); + CHECK(screen(v, false) == Screen::Done); + CHECK(screen(v, true) == Screen::Rewear); + v.info.nextWear = kWearButton; + CHECK(screen(v, true) == Screen::Ready); + // Being deleted: that, whatever else + v.progress.job = Job::Delete; + v.progress.running = true; + CHECK(screen(v, false) == Screen::Deleting); +} + +/** + * Whether a tar header's checksum adds up. + * @param header the 512 bytes + * @return true if it does + */ +bool checksumOk(const std::string& header) { + unsigned int sum = 0; + for (size_t i = 0; i < 512; ++i) sum += i >= 148 && i < 156 ? ' ' : static_cast(header[i]); + return std::strtoul(header.substr(148, 7).c_str(), nullptr, 8) == sum; +} + +void testTarHeader() { + using contrib::Entry; + Entry file {"/x/eye_L.raw", "contrib_2026-10-25_14-02-10/rec_2026-10-25_14-02-41/eye_L.raw", 1'000'320'000, + 1'790'000'000, false}; + const std::string header = contrib::tarHeader(file); + CHECK(header.size() == 512); + SAME(header.substr(0, file.name.size()), file.name); + CHECK(header[file.name.size()] == '\0'); + SAME(header.substr(124, 11), "07347726000"); // 1000320000 in octal + CHECK(header[156] == '0'); + SAME(header.substr(257, 6), std::string("ustar\0", 6)); + SAME(header.substr(263, 2), "00"); + SAME(header.substr(100, 7), "0000644"); + CHECK(checksumOk(header)); + // A folder + const Entry folder {"/x", "contrib_2026-10-25_14-02-10/", 0, 1'790'000'000, true}; + const std::string dir = contrib::tarHeader(folder); + CHECK(dir[156] == '5'); + SAME(dir.substr(100, 7), "0000755"); + SAME(dir.substr(124, 11), "00000000000"); + CHECK(checksumOk(dir)); + // 8 GiB and more: GNU's base-256 + Entry big = file; + big.size = 9'000'000'000LL; + const std::string large = contrib::tarHeader(big); + CHECK(static_cast(large[124]) == 0x80); + unsigned long long size = 0; + for (int i = 125; i < 136; ++i) size = size << 8 | static_cast(large[i]); + CHECK(size == 9'000'000'000ULL); + CHECK(checksumOk(large)); + // A name of 100 bytes or more doesn't fit + Entry long_ = file; + long_.name = std::string(100, 'a'); + CHECK(contrib::tarHeader(long_).empty()); + long_.name = std::string(99, 'a'); + CHECK(contrib::tarHeader(long_).size() == 512); +} + +/** + * Make a recording's folder as eyecam-rec leaves one. + * @param home where + * @param name its name + * @param bytes the size of its eye_L.raw (eye_R.raw is a little smaller) + * @param made when it was made (its modification time; the tests' clock runs ahead of the real one) + * @return its path + */ +std::string makeRecording(const std::string& home, const std::string& name, size_t bytes, double made = kNow) { + const std::string dir = home + "/" + name; + ::mkdir(dir.c_str(), 0755); + std::string video(bytes, '\0'); + for (size_t i = 0; i < bytes; ++i) video[i] = static_cast((i * 7 + i / 400) & 0xff); + writeFile(dir + "/eye_L.raw", video); + writeFile(dir + "/eye_R.raw", video.substr(0, bytes - 13)); + writeFile(dir + "/cues.csv", "t,label\n0.0,lead_in\n"); + writeFile(dir + "/valve.csv", "t,openness_l\n"); + writeFile(dir + "/frames.csv", "eye,t_cam\n"); + writeFile(dir + "/meta.txt", "protocol=widen_nolight\n"); + const timespec times[2] = {{static_cast(made), 0}, {static_cast(made), 0}}; + ::utimensat(AT_FDCWD, dir.c_str(), times, 0); + return dir; +} + +void testFiles() { + using namespace contrib; + const RemoveDir temp {tempDir()}; + const std::string home = temp.path + "/eyecam"; + // None yet; a folder that isn't one is never taken + ::mkdir(home.c_str(), 0755); + CHECK(findCurrent(home).empty()); + ::mkdir((home + "/contrib_2026-01-01_00-00-00").c_str(), 0755); + CHECK(findCurrent(home).empty()); // (no info.txt) + // Made with the consent + std::string dir; + Info info; + std::string error; + CHECK(create(home, "0.8.2-test", static_cast(kNow), dir, info, error)); + SAME(dir, home + "/" + folderName(static_cast(kNow))); + SAME(info.appVersion, "0.8.2-test"); + CHECK(!info.consent.empty()); + SAME(findCurrent(home), dir); + Info read; + CHECK(readInfo(dir, read, error)); + SAME(read.consent, info.consent); + SAME(read.steps[3].protocol, kFreeProtocol); + // A second in the same second: the next second's name; the newest is the current one + std::string second; + Info secondInfo; + CHECK(create(home, "0.8.2-test", static_cast(kNow), second, secondInfo, error)); + CHECK(second != dir && isFolderName(second.substr(home.size() + 1))); + SAME(findCurrent(home), second); + std::filesystem::remove_all(second); + // A recording moved in, and one that didn't finish into aborted/ + const std::string rec = makeRecording(home, "rec_2026-10-25_14-02-41", 4096); + std::string name; + CHECK(takeRecording(home, dir, rec, false, name, error)); + SAME(name, "rec_2026-10-25_14-02-41"); + CHECK(::access((dir + "/" + name + "/eye_L.raw").c_str(), F_OK) == 0); + CHECK(::access(rec.c_str(), F_OK) != 0); + const std::string part = makeRecording(home, "rec_2026-10-25_14-04-00", 1000); + CHECK(takeRecording(home, dir, part, true, name, error)); + SAME(name, "aborted/rec_2026-10-25_14-04-00"); + // Only eyecam-rec's own folders right in home + const std::string elsewhere = temp.path + "/rec_2026-10-25_14-09-00"; + ::mkdir(elsewhere.c_str(), 0755); + CHECK(!takeRecording(home, dir, elsewhere, false, name, error)); + ::mkdir((home + "/calib_2026-10-25_14-09-00").c_str(), 0755); + CHECK(!takeRecording(home, dir, home + "/calib_2026-10-25_14-09-00", false, name, error)); + CHECK(!takeRecording(home, dir, home + "/rec_2026-10-25_14-59-59", false, name, error)); // (not there) + // Sizes: the files in it and its folders + CHECK(folderBytes(dir + "/rec_2026-10-25_14-02-41") == + 4096 + 4096 - 13 + 20 + 13 + 10 + 23); + CHECK(folderBytes(dir) > folderBytes(dir + "/rec_2026-10-25_14-02-41")); + CHECK(freeBytes(home) > 0); + CHECK(freeBytes(temp.path + "/missing") == -1); + // info.txt written over (no temporary left) + info.steps[0].rec = "rec_2026-10-25_14-02-41"; + CHECK(writeInfo(dir, info, error)); + CHECK(readInfo(dir, read, error)); + SAME(read.steps[0].rec, "rec_2026-10-25_14-02-41"); + CHECK(::access((dir + "/info.txt.tmp").c_str(), F_OK) != 0); +} + +/** + * Wait for the worker's job to end. + * @param worker the worker + * @return how it ended + */ +contrib::Progress waitFor(contrib::Worker& worker) { + contrib::Progress ended; + for (int i = 0; i < 3000 && !worker.takeFinished(ended); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return ended; +} + +/** + * Run a shell command. + * @param command the command + * @return true if it exited 0 + */ +bool run(const std::string& command) { + return std::system(command.c_str()) == 0; +} + +void testPack() { + using namespace contrib; + const RemoveDir temp {tempDir()}; + const std::string home = temp.path + "/eyecam"; + ::mkdir(home.c_str(), 0755); + std::string dir; + Info info; + std::string error; + CHECK(create(home, "0.8.2-test", static_cast(kNow), dir, info, error)); + const std::string names[2] = {"rec_2026-10-25_14-02-41", "rec_2026-10-25_14-05-12"}; + std::string name; + takeRecording(home, dir, makeRecording(home, names[0], 300'000), false, name, error); + takeRecording(home, dir, makeRecording(home, names[1], 1'500'000), false, name, error); + takeRecording(home, dir, makeRecording(home, "rec_2026-10-25_14-04-00", 1000), true, name, error); + info.steps[0].rec = names[0]; + info.steps[1].rec = names[1]; + CHECK(writeInfo(dir, info, error)); + // What goes in: the folder, info.txt, the finished recordings (sorted), not aborted/ + std::vector entries; + CHECK(packEntries(dir, info, entries, error)); + CHECK(entries.size() == 2 + 2 * 7); + const std::string top = dir.substr(home.size() + 1); + SAME(entries[0].name, top + "/"); + SAME(entries[1].name, top + "/info.txt"); + SAME(entries[2].name, top + "/" + names[0] + "/"); + SAME(entries[3].name, top + "/" + names[0] + "/cues.csv"); + for (const Entry& entry : entries) CHECK(entry.name.find("aborted") == std::string::npos); + // A finished one gone missing: nothing is packed + Info missing = info; + missing.steps[2].rec = "rec_2026-10-25_15-00-00"; + CHECK(!packEntries(dir, missing, entries, error)); + // Packed as tar: unpacks to the same files + Worker worker; + CHECK(worker.pack(dir, info, false)); + Progress ended = waitFor(worker); + CHECK(ended.finished && ended.ok); + SAME(ended.output, top + ".tar"); + CHECK(ended.done == ended.total && ended.total > 0); + const std::string tar = dir + "/" + top + ".tar"; + CHECK(::access(tar.c_str(), F_OK) == 0); + CHECK(::access((tar + ".part").c_str(), F_OK) != 0); + CHECK(ended.outputBytes == static_cast(std::filesystem::file_size(tar))); + const std::string out = temp.path + "/out"; + ::mkdir(out.c_str(), 0755); + CHECK(run("tar -xf '" + tar + "' -C '" + out + "'")); + for (const std::string& rec : names) { + for (const char* file : {"eye_L.raw", "eye_R.raw", "cues.csv", "valve.csv", "frames.csv", "meta.txt"}) { + const std::string a = readFile(dir + "/" + rec + "/" + file); + const std::string b = readFile(out + "/" + top + "/" + rec + "/" + file); + CHECK(!a.empty() && a == b); + } + } + SAME(readFile(out + "/" + top + "/info.txt"), readFile(dir + "/info.txt")); + CHECK(::access((out + "/" + top + "/aborted").c_str(), F_OK) != 0); + // ...and through zstd, when it is there + if (zstdAvailable()) { + CHECK(worker.pack(dir, info, true)); + ended = waitFor(worker); + CHECK(ended.finished && ended.ok); + SAME(ended.output, top + ".tar.zst"); + const std::string zst = dir + "/" + top + ".tar.zst"; + const std::string out2 = temp.path + "/out2"; + ::mkdir(out2.c_str(), 0755); + CHECK(run("zstd -dc '" + zst + "' | tar -xf - -C '" + out2 + "'")); + SAME(readFile(out2 + "/" + top + "/" + names[1] + "/eye_L.raw"), + readFile(dir + "/" + names[1] + "/eye_L.raw")); + // (the earlier pack and aborted/ aren't in it) + CHECK(::access((out2 + "/" + top + "/" + top + ".tar").c_str(), F_OK) != 0); + } else { + std::printf("contrib-test: no zstd here, its pack not tried\n"); + } + // Deleting: only a contribution's folder right in home + CHECK(!worker.remove(home, dir + "/" + names[0])); + CHECK(!worker.remove(temp.path, dir)); + CHECK(!worker.remove(home, home + "/contrib_2026-10-25_99-99-99")); + CHECK(worker.remove(home, dir)); + ended = waitFor(worker); + CHECK(ended.finished && ended.ok && ended.job == Job::Delete); + CHECK(::access(dir.c_str(), F_OK) != 0); + CHECK(::access(home.c_str(), F_OK) == 0); +} + +void testSession() { + using namespace contrib; + const RemoveDir temp {tempDir()}; + const std::string home = temp.path + "/eyecam"; + ::mkdir(home.c_str(), 0755); + View view; + Session session; + std::string log; + session.load(view, home); + CHECK(view.loaded && view.dir.empty() && view.freeBytes > 0); + CHECK(screen(view, false) == Screen::Consent); + const double t0 = static_cast(std::time(nullptr)); + double now = t0; + double boot = 1000; + eyecam::View eyecam = eyecamReady(now); + // No start before the consent + CHECK(session.startCommand(view, eyecam, 0).empty()); + CHECK(session.consent(view, "0.8.2-test", static_cast(now))); + CHECK(!view.dir.empty()); + CHECK(!session.consent(view, "0.8.2-test", static_cast(now))); + // Step 1: no re-wear before it + SAME(session.startCommand(view, eyecam, 0), "start widen_nolight"); + CHECK(session.startCommand(view, eyecam, 1).empty()); + session.started(view, 0, "start widen_nolight", now); + CHECK(view.running == 0 && screen(view, false) == Screen::Running); + // (the start went out: nothing more until its reply) + CHECK(session.startCommand(view, eyecam, 0).empty()); + eyecam::Reply ok; + ok.ok = true; + ok.command = "start widen_nolight"; + CHECK(!session.replied(view, ok)); + // Recording, then idle with its folder: moved into the contribution + now += 1; + eyecam.status = status(State::Recording, kGuidedProtocol, now); + session.follow(view, eyecam, now, boot, log); + CHECK(view.running == 0); + const std::string rec1 = makeRecording(home, "rec_2026-10-25_14-02-41", 2000, now); + now += 61; + eyecam.status = status(State::Idle, kGuidedProtocol, now); + eyecam.status.sessionDir = rec1; + CHECK(session.follow(view, eyecam, now, boot, log)); + CHECK(view.running == -1); + SAME(view.info.steps[0].rec, "rec_2026-10-25_14-02-41"); + SAME(view.info.steps[0].wear, kWearFirst); + CHECK(view.info.steps[0].bytes > 4000); + CHECK(::access((view.dir + "/rec_2026-10-25_14-02-41/cues.csv").c_str(), F_OK) == 0); + CHECK(log.find("step 1 recorded") != std::string::npos); + // Step 2 waits for the re-wear: the video stops, then comes back + CHECK(screen(view, false) == Screen::Rewear); + CHECK(session.startCommand(view, eyecam, 1).empty()); + for (int i = 0; i < 40; ++i) { + now += 0.1; + boot += 0.1; + eyecam.status = status(State::Idle, kGuidedProtocol, now); + eyecam.status.locked = false; + session.follow(view, eyecam, now, boot, log); + } + CHECK(view.offSeen && view.info.nextWear.empty()); + for (int i = 0; i < 25; ++i) { + now += 0.1; + boot += 0.1; + eyecam.status = status(State::Idle, kGuidedProtocol, now); + session.follow(view, eyecam, now, boot, log); + } + SAME(view.info.nextWear, kWearDetected); + CHECK(screen(view, false) == Screen::Ready); + // Step 2 stopped part way: its part in aborted/, the step again (its re-wear kept) + eyecam = eyecamReady(now); + SAME(session.startCommand(view, eyecam, 1), "start widen_nolight"); + session.started(view, 1, "start widen_nolight", now); + session.replied(view, ok); + now += 1; + eyecam.status = status(State::Recording, kGuidedProtocol, now); + session.follow(view, eyecam, now, boot, log); + const std::string part = makeRecording(home, "rec_2026-10-25_14-05-12", 500, now); + now += 20; + eyecam.status = status(State::Idle, kGuidedProtocol, now); + eyecam.status.sessionDir = part; + eyecam.status.lastSessionAborted = true; + CHECK(session.follow(view, eyecam, now, boot, log)); + CHECK(view.failure.step == 1 && view.failure.why == Why::Stopped); + SAME(view.failure.kept, "aborted/rec_2026-10-25_14-05-12"); + CHECK(view.info.steps[1].rec.empty()); + CHECK(view.info.aborted.size() == 1); + CHECK(screen(view, false) == Screen::Failed); + SAME(view.info.nextWear, kWearDetected); + // Refused at once (a reply with an error): failed with its reason + eyecam = eyecamReady(now); + session.started(view, 1, "start widen_nolight", now); + eyecam::Reply refused; + refused.command = "start widen_nolight"; + refused.error = "プロトコルが見つからない: protocol_widen_nolight.txt"; + CHECK(session.replied(view, refused)); + CHECK(view.running == -1 && view.failure.why == Why::Refused); + SAME(view.failure.message, refused.error); + // The panel restarts while step 2 records: followed on after the load, done + session.started(view, 1, "start widen_nolight", now); + session.replied(view, ok); + { + View again; + Session restarted; + restarted.load(again, home); + SAME(again.dir, view.dir); + CHECK(again.running == 1); + const std::string rec2 = makeRecording(home, "rec_2026-10-25_14-08-03", 800, now); + now += 70; + eyecam.status = status(State::Idle, kGuidedProtocol, now); + eyecam.status.sessionDir = rec2; + CHECK(restarted.follow(again, eyecam, now, boot, log)); + SAME(again.info.steps[1].rec, "rec_2026-10-25_14-08-03"); + SAME(again.info.steps[1].wear, kWearDetected); + CHECK(again.info.nextWear.empty()); + // "I put it back on" for step 3 (only once a step waits for one) + CHECK(restarted.rewore(again)); + CHECK(!restarted.rewore(again)); + Info onDisk; + std::string error; + CHECK(readInfo(again.dir, onDisk, error)); + SAME(onDisk.nextWear, kWearButton); + CHECK(onDisk.running == -1); + view = again; + session.load(view, home); + } + // Steps 3 and 4 (the free use) done: the end, and its pack + for (int step = 2; step < kRequiredSteps; ++step) { + if (view.info.nextWear.empty()) CHECK(session.rewore(view)); + eyecam = eyecamReady(now); + SAME(session.startCommand(view, eyecam, step), std::string("start ") + protocolOf(step)); + session.started(view, step, std::string("start ") + protocolOf(step), now); + // (followed only once its start is answered) + eyecam::Reply started = ok; + started.command = std::string("start ") + protocolOf(step); + CHECK(!session.replied(view, started)); + now += 1; + eyecam.status = status(State::Recording, protocolOf(step), now); + session.follow(view, eyecam, now, boot, log); + const std::string rec = + makeRecording(home, step == 2 ? "rec_2026-10-25_14-11-40" : "rec_2026-10-25_14-15-00", 600, now); + now += 60; + eyecam.status = status(State::Idle, protocolOf(step), now); + eyecam.status.sessionDir = rec; + CHECK(session.follow(view, eyecam, now, boot, log)); + CHECK(!view.info.steps[step].rec.empty()); + } + CHECK(requiredDone(view.info) == kRequiredSteps); + CHECK(screen(view, false) == Screen::Done); + SAME(view.info.steps[3].protocol, kFreeProtocol); + view.compress = false; + CHECK(session.pack(view)); + for (int i = 0; i < 3000 && view.info.packed.empty(); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + session.follow(view, eyecam, now, boot, log); + } + SAME(view.info.packed, view.dir.substr(home.size() + 1) + ".tar"); + CHECK(view.info.packedBytes > 0); + CHECK(view.totalBytes > view.info.packedBytes); + // Deleted: the first screen again, saying so + CHECK(session.remove(view)); + for (int i = 0; i < 3000 && !view.dir.empty(); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + session.follow(view, eyecam, now, boot, log); + } + CHECK(view.dir.empty() && view.deleted); + CHECK(screen(view, false) == Screen::Consent); + CHECK(findCurrent(home).empty()); +} + +} // namespace + +int main() { + testSteps(); + testProtocolFiles(); + testInfo(); + testNames(); + testRunWatch(); + testWearWatch(); + testScreen(); + testTarHeader(); + testFiles(); + testPack(); + testSession(); + if (gFailures > 0) { + std::fprintf(stderr, "%d check(s) failed\n", gFailures); + return 1; + } + std::printf("contrib-test: all passed\n"); + return 0; +} diff --git a/panel/tests/panel_test.cpp b/panel/tests/panel_test.cpp index 638b63d..1a7066b 100644 --- a/panel/tests/panel_test.cpp +++ b/panel/tests/panel_test.cpp @@ -8,8 +8,9 @@ // bar, the Eye fit tab while a fit runs (only "Stop" and the tabs), the icons in texts (measured and drawn in their // own boxes, never starting a line, cut whole), and the other Frame apps (the row at the bottom of "Version" and its // count, the list's rows by state, the confirmation's command and buttons, scrolling), and the eye preview (its button -// in the left column, alone or beside a notice; its sheet's buttons, what closes it, freeze, without the cameras). Built with -// the panel as panel-test (it renders offscreen with the panel's fonts) and +// in the left column, alone or beside a notice; its sheet's buttons, what closes it, freeze, without the cameras), and +// "Help train the AI" (its row on Debug tools, each screen's buttons, its recording, the delete's confirmation). Built +// with the panel as panel-test (it renders offscreen with the panel's fonts) and // run after it is built; exits non-zero on failure. #include "config.h" #include "draw.h" @@ -2986,9 +2987,153 @@ void testPreview(const FontSet& fonts) { } } +/** + * The usable buttons with this action and argument as last drawn. + * @param panel the panel + * @param action the action + * @param arg its argument + * @return how many + */ +size_t hitsArg(const EyePanel& panel, PanelAction action, int arg) { + size_t count = 0; + for (const EyePanel::HitArea& area : hits(panel, action)) count += area.hit.arg == arg ? 1 : 0; + return count; +} + +void testContrib(const FontSet& fonts) { + // "Help train the AI": its row on Debug tools (only while eyecam-rec runs, usable once the cameras are set up), + // each screen's buttons in both languages, its recording shown in it, the delete's confirmation, and what closes it + { + EyePanel panel(fonts); + panel.setTab(PanelTab::Advanced); + panel.setAdvPage(AdvPage::Tools); + panel.render(modelWith(Lids::Both, false)); + CHECK(hits(panel, PanelAction::ContribOpen).empty()); + PanelModel notSetUp = modelWith(Lids::Both, true); + notSetUp.eyecam.status.setupDone = false; + notSetUp.eyecam.status.calibState = 0; + panel.render(notSetUp); + CHECK(hits(panel, PanelAction::ContribOpen).empty()); + } + for (const Language language : {Language::Ja, Language::En}) { + EyePanel panel(fonts); + panel.setTab(PanelTab::Advanced); + panel.setAdvPage(AdvPage::Tools); + PanelModel m = modelWith(Lids::Both, true); + m.language = language; + m.contrib.loaded = true; + m.contrib.freeBytes = 30'000'000'000LL; + panel.render(m); + CHECK(press(panel, PanelAction::ContribOpen).action == PanelAction::ContribOpen); + CHECK(panel.contribOpen() && !panel.advPageShown()); + // The consent: "I'll help", the cross and "Cancel"; not without the room for it + panel.render(m); + CHECK(hits(panel, PanelAction::ContribConsent).size() == 1); + CHECK(hits(panel, PanelAction::ContribClose).size() == 2); + CHECK(hits(panel, PanelAction::AdvancedPage).empty()); + m.contrib.freeBytes = 5'000'000'000LL; + panel.render(m); + CHECK(hits(panel, PanelAction::ContribConsent).empty()); + m.contrib.freeBytes = 30'000'000'000LL; + // Begun: the first step's start (no re-wear before it); none while a command waits + m.contrib.dir = "/home/steamos/eyecam/contrib_2026-10-25_14-02-10"; + panel.render(m); + CHECK(hitsArg(panel, PanelAction::ContribStart, 0) == 1); + CHECK(hits(panel, PanelAction::ContribDelete).size() == 1); + m.eyecam.busy = true; + panel.render(m); + CHECK(hits(panel, PanelAction::ContribStart).empty()); + m.eyecam.busy = false; + // After it: the re-wear, "I put it back on" only once it can be pressed; then the second step's start + m.contrib.info.steps[0].rec = "rec_2026-10-25_14-02-41"; + panel.render(m); + CHECK(hits(panel, PanelAction::ContribStart).empty() && hits(panel, PanelAction::ContribRewore).empty()); + m.contrib.fallbackReady = true; + panel.render(m); + CHECK(hits(panel, PanelAction::ContribRewore).size() == 1); + m.contrib.info.nextWear = contrib::kWearDetected; + panel.render(m); + CHECK(hitsArg(panel, PanelAction::ContribStart, 1) == 1); + // Its recording runs in the sheet: the eye capture's run with its "Stop" + m.contrib.running = 1; + eyecam::Status& s = m.eyecam.status; + s.state = eyecam::State::Recording; + s.stateText = "recording"; + s.locked = true; + s.protocol = contrib::kGuidedProtocol; + s.stepLabel = "widen"; + s.stepCount = 14; + s.stepIndex = 3; + s.elapsedS = 10; + s.totalS = 60.5; + s.stepRemainingS = 3; + panel.render(m); + CHECK(hits(panel, PanelAction::EyecamStop).size() == 1); + CHECK(hits(panel, PanelAction::ContribStart).empty() && hits(panel, PanelAction::ContribDelete).empty()); + s.state = eyecam::State::Idle; + s.stateText = "idle"; + m.contrib.running = -1; + // It didn't finish: "Record again" for that step + m.contrib.failure.step = 1; + m.contrib.failure.why = contrib::Why::Stopped; + panel.render(m); + CHECK(hitsArg(panel, PanelAction::ContribStart, 1) == 1); + m.contrib.failure = contrib::Failure(); + // All four: pack, delete, the optional one + for (int step = 1; step < contrib::kRequiredSteps; ++step) { + m.contrib.info.steps[step].rec = "rec_2026-10-25_14-05-12"; + } + m.contrib.info.nextWear.clear(); + panel.render(m); + CHECK(hits(panel, PanelAction::ContribPack).size() == 1); + CHECK(hits(panel, PanelAction::ContribDelete).size() == 1); + CHECK(hitsArg(panel, PanelAction::ContribExtra, 1) == 1); + CHECK(hits(panel, PanelAction::ContribStart).empty()); + // ...the optional one: its re-wear first, then its start and "Back" + CHECK(pressArg(panel, PanelAction::ContribExtra, 1).action == PanelAction::None); + CHECK(panel.contribExtra()); + panel.render(m); + CHECK(hits(panel, PanelAction::ContribRewore).size() == 1); + m.contrib.info.nextWear = contrib::kWearButton; + panel.render(m); + CHECK(hitsArg(panel, PanelAction::ContribStart, 4) == 1); + CHECK(pressArg(panel, PanelAction::ContribExtra, 0).action == PanelAction::None); + CHECK(!panel.contribExtra()); + // Packing or packed: no second one, and no delete while packing + m.contrib.progress.job = contrib::Job::Pack; + m.contrib.progress.running = true; + panel.render(m); + CHECK(hits(panel, PanelAction::ContribPack).empty() && hits(panel, PanelAction::ContribDelete).empty()); + m.contrib.progress = contrib::Progress(); + m.contrib.info.packed = "contrib_2026-10-25_14-02-10.tar.zst"; + panel.render(m); + CHECK(hits(panel, PanelAction::ContribPack).empty()); + // Deleting: its confirmation first (only its buttons), "Cancel" back, then the delete + CHECK(press(panel, PanelAction::ContribDelete).action == PanelAction::None); + panel.render(m); + CHECK(hits(panel, PanelAction::ContribDeleteConfirm).size() == 1); + CHECK(hits(panel, PanelAction::ContribDelete).empty() && hits(panel, PanelAction::ContribExtra).empty()); + CHECK(press(panel, PanelAction::ContribDeleteCancel).action == PanelAction::None); + panel.render(m); + CHECK(hits(panel, PanelAction::ContribDeleteConfirm).empty()); + CHECK(hits(panel, PanelAction::ContribDelete).size() == 1); + press(panel, PanelAction::ContribDelete); + panel.render(m); + CHECK(press(panel, PanelAction::ContribDeleteConfirm).action == PanelAction::ContribDeleteConfirm); + // A tab closes it; the sub-tabs show again + pressTab(panel, PanelTab::Basic); + CHECK(!panel.contribOpen()); + panel.render(m); + pressTab(panel, PanelTab::Advanced); + panel.render(m); + CHECK(panel.advPageShown() && hits(panel, PanelAction::ContribOpen).size() == 1); + } +} + int main() { FontSet fonts; fonts.load(kFontPath, kBoldFontPath); + testContrib(fonts); testWidenRouting(fonts); testWidenSwitch(fonts); testLidVetoSwitch(fonts); From 9f735b895273d5ac3e4437de61d415dc10d11d05 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 12:03:09 +0900 Subject: [PATCH 34/65] =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88:=20=E3=80=8CAI=20=E3=81=AE=E5=AD=A6=E7=BF=92?= =?UTF-8?q?=E3=81=AB=E5=8D=94=E5=8A=9B=E3=81=99=E3=82=8B=E3=80=8D=E3=81=AE?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E3=81=94=E3=81=A8=E3=81=AE=E4=BB=95=E6=A7=98?= =?UTF-8?q?=E3=80=81=E7=BD=AE=E3=81=8F=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=80=81=E6=9B=B4=E6=96=B0=E5=B1=A5=E6=AD=B4=EF=BC=88Unrelease?= =?UTF-8?q?d=EF=BC=89=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 10 ++++++++++ CHANGELOG.md | 12 ++++++++++++ README.ja.md | 4 ++-- README.md | 4 ++-- panel/README.md | 24 +++++++++++++++++++++++- 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 33f227b..09fdd46 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -1,5 +1,15 @@ # 更新履歴 +## Unreleased + +パネルに「AI の学習に協力する」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるために、目の録画を開発者へ送る手伝いを、手順どおりに進められます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ始めます。 + +- 「AI の学習に協力する」: 最初の画面で、録るもの(目のカメラの映像。約 10.5 GB。送るまでは Frame の中だけ)、使いみち(半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、要る空き容量と今の空きを見せます。[協力する]で `~/eyecam/contrib_日時/` と `info.txt`(アプリの版、同意した時刻、ステップごとのプロトコル・フォルダ・大きさ・かぶり直しをどう知ったか。個人の情報は入れない)を作ります +- そのあと 4 回の録画(チップで進み具合を表示): eyecam の光なしの撮り方(`widen_nolight`、約 1 分)を 3 回と、ふだん使い 3 分(`free`。そのあいだにすることを画面に出す)。ふだん使いをもう 1 回は任意です。2 回目からは、始める前に HMD をいったん外してかぶり直すように頼み、それを eyecam-rec の状態から自動で見分けます(目の映像が止まって戻った・近接センサー・Frame がスリープした)。見分けられない HMD のために、頼んでから 20 秒たつと[かぶり直した]を押せます +- 録画は目の撮影と同じように進み(ビープ・ステップ・[中止])、終わったら協力のフォルダに移します。途中で止まった・失敗したものは `aborted/`(まとめるファイルには入れない)に入れ、そのステップを録り直せます。進み具合は `info.txt` に残るので、パネルや Frame を起動し直しても続きから(録画の途中でも)できます +- 最後に合計の大きさとフォルダ、[1 つのファイルにまとめる](zstd があれば `.tar.zst`、なければ `.tar`。空き容量を確かめてから、低い優先度で裏でまとめる。元の録画は残す)、送ったあと用の[この録画を消す](確認つき)を出します +- `frameeyeosc-panel --dump-png … --fake-contrib 状態` で、それぞれの画面を描けます + ## 0.8.2-beta.2 (2026-10-24) SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。度付きインサートを使っているときも、目のカメラが上まぶたを見つけやすくなるはずです。見開きが出るようになり、開いた目が細めに読まれることも減ります。 diff --git a/CHANGELOG.md b/CHANGELOG.md index e73536b..f4b1f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +New in the panel: "Help train the AI" (Advanced › Debug tools, once the eye cameras are set up) guides you through recording your eyes for the developer, to train the "Half-shut check (AI)" on more people's eyes. It is entirely optional: it says first what is recorded and how it is used, and starts only when you agree. + +日本語: パネルに「AI の学習に協力する」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるために、目の録画を開発者へ送る手伝いを、手順どおりに進められます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ始めます。 + +- "Help train the AI": a first screen says what is recorded (eye-camera video, about 10.5 GB, staying on the Frame until you send it), how it is used (only to tune the half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and the free space against what it needs; "I'll help" makes `~/eyecam/contrib_/` with an `info.txt` (the app version, the consent's time, each step's protocol, folder, size and how its re-wear was known; no personal data). +- Then four recordings, shown as chips: eyecam's protocol without light (`widen_nolight`, about 1 minute) three times, and 3 minutes of normal use (`free`, with what to do during it on screen); a second normal use is optional. Before each one after the first, it asks you to take the headset off and put it back on, and notices that by itself from eyecam-rec's status (the eye video stopping and coming back, the proximity sensor, or the Frame sleeping); "I put it back on" can be pressed 20 s after it asks, for a headset where that isn't seen. +- Each recording runs as the eye capture does (beeps, steps, "Stop") and is moved into the contribution's folder once it has ended; one that stopped early or failed goes to its `aborted/` (never packed) and that step can be recorded again. The progress is kept in `info.txt`, so it carries on after the panel or the Frame restarts, also with a recording that was running. +- At the end: the total size and the folder, "Make one file" (a `.tar.zst` through zstd, or a `.tar` without it, packed in the background at a low priority after checking the free space; the recordings stay), and "Delete these recordings" (after a confirmation) for once it has been sent. +- `frameeyeosc-panel --dump-png … --fake-contrib STATE` draws each of its screens. + ## 0.8.2-beta.2 (2026-10-24) The gaze no longer stays frozen when SteamOS keeps reading one open eye as closed: after a second, the other eye moves the gaze (#32). With "Move eyes separately" on, the new "Own gaze per eye" (Gaze › Reach) lets each eye's own sideways gaze through, so looking inward with both eyes can show. The eye cameras no longer lose a pupil that suddenly looks larger, the camera eyelids no longer take over for good after many blinks on SteamOS 0.4.x, and a new user calibration now sets the squint at the same depth whatever the wear. With prescription inserts, the eye cameras should find the upper eyelid more reliably, so widening can show and an open eye is less often read as a squint. diff --git a/README.ja.md b/README.ja.md index 73cdfcc..46a1372 100644 --- a/README.ja.md +++ b/README.ja.md @@ -133,7 +133,7 @@ sudo は要りません。全部ホームフォルダ(`~/.local/bin`、`~/.loc - 目を合わせる: ボタン 1 つで正面・上の幅・まぶたを約 20 秒で合わせる([目を合わせる](#目を合わせる) を参照)、[真ん中だけ合わせ直す](真ん中の点 1 つ)、被ったときに自動で合わせ直すもの(「被ったとき」: 何もしない・真ん中だけ)、結果と[元に戻す]、「細かく直す」の中で値を手で直す(閉じているあいだは、合わせ方の箱が出ます) - まぶた: いちばん上の切り替えで 4 ページ(初めては「見た目」、あとは最後に開いたページ。パネルを起動し直しても開きます)。「見た目」: いまのまぶたがどこから来ているか(目のカメラ・片目だけ目のカメラ・Valve の値。カメラのオン / オフは「基本」タブで)、「見開き」(する / しない、`widen`。しないと、カメラがあってもなくても、まぶたはふつうに開いた所より上に行かない。閉じる・細めはそのまま。そのあいだ下のスライダーは灰色)、目のカメラを使っているときは「見開きの出やすさ」(鈍い〜敏感のスライダー 1 本。カメラの目にはその感度、Valve の値の目には目を合わせた目の 4 段階(しない / 控えめ / ふつう / 出やすい、`lid_widen`))、目のカメラを使っているときは「半目の読み直し(AI)」(する / しない。eyecam のまぶたの拒否権で、既定はしない。横を見たときなどに開いた目が半目に読まれたら、小さな AI が見直して開いた目に戻す。軽い細めも開いた目に戻ることがある。まだ試しの機能で、作った人の目だけで学習している)、まぶたのなめらかさ(弱 / 中 / 強)、左右をそろえる強さ(`lid_sync`)。「まばたき・細め」: まばたきを届ける(閉じたまま保つ時間)、まばたきを両目でそろえる(`blink_sync_below`)、目のカメラを使っているときは細めたときのまぶたの下限(`camera_lid_floor`。効くのはカメラの目があるとき)。「細かく直す」: 自動キャリブレーションと覚えた値、左右の倍率、左右の今の開き具合の上に重ねた 4 つの目盛り(目を閉じたり見開いたりしながら合わせる。③④は目を合わせた目とカメラの目では灰色)、「ほぼ開いてたら全開にする」(`lid_open_snap`)、なめらかさの 2 つの値。「眉」: 「見開きで眉も上げる」(する / しない、`brow_from_widen`。初めはしない。カメラは眉を見ていないので、アバターの眉は見開きについていく。「見開き」が「しない」でも上がる)と、「する」のあいだだけ「眉の上がり方」(`brow_strength`、弱い〜強い) - 目のカメラ: eyecam が動いているあいだ出ます。準備を始める前は「目のカメラでできること」と[はじめる(約 2 分)]、押すと準備のチェックリスト([目のカメラ](#目のカメラ))。済んだあとは、いまの状態(オン / オフは「基本」タブで)とその下の[普段の目を覚え直す](約 30 秒で普段の目を覚え直す。そのあいだも今までの値でまぶた・見開き・細めが動き、覚え終わったら新しい値に移る。校正の値はそのまま)、違和感があるときの「目のカメラの校正(18秒)」、目を細めた動きも送りたいときの「ユーザー校正(最初に 1 回)」、こんなときはどうするか -- 詳細: いちばん上の切り替えで「バージョン」「困ったとき」「動作確認」「ファイル」の 4 ページ(初めては「バージョン」、そのあとは最後に開いたページ。パネルを起動し直しても)。「バージョン」: 版の表示と更新の確認・更新(自動の確認のオン / オフも)と「更新履歴」(版ごとの要約と変更点、新しい順。開くのは 1 つずつで、スティックか右の上下の山形のボタンでスクロール)。「困ったとき」: 困ったときにスクショを送る「診断」のページ([うまく動かないとき](#うまく動かないとき)を参照)と、校正と目合わせの最近の記録(下を参照)。「動作確認」: 視線の点を表示と点の距離、目のログの記録(下を参照)、eyecam が動いているあいだは「開発用」の「目の撮影」(目の処理を調整するための目のカメラの録画で、目の映像が `~/eyecam/rec_…/` に残ります。1 回で約 2.5 GB。[panel/README.md](panel/README.md) を参照)。「ファイル」: ファイルの場所、本体の PID、コマンドで固定中の項目 +- 詳細: いちばん上の切り替えで「バージョン」「困ったとき」「動作確認」「ファイル」の 4 ページ(初めては「バージョン」、そのあとは最後に開いたページ。パネルを起動し直しても)。「バージョン」: 版の表示と更新の確認・更新(自動の確認のオン / オフも)と「更新履歴」(版ごとの要約と変更点、新しい順。開くのは 1 つずつで、スティックか右の上下の山形のボタンでスクロール)。「困ったとき」: 困ったときにスクショを送る「診断」のページ([うまく動かないとき](#うまく動かないとき)を参照)と、校正と目合わせの最近の記録(下を参照)。「動作確認」: 視線の点を表示と点の距離、目のログの記録(下を参照)、eyecam が動いているあいだは「開発用」の「目の撮影」(目の処理を調整するための目のカメラの録画で、目の映像が `~/eyecam/rec_…/` に残ります。1 回で約 2.5 GB。[panel/README.md](panel/README.md) を参照)と、目のカメラの準備が済んだら「AI の学習に協力する」(「半目の読み直し(AI)」をいろんな人の目で学習させるために、開発者へ送る目の録画。任意です。HMD をかぶり直すたびに約 1 分の決まった動きを 3 回と、ふだん使い 3 分。約 10.5 GB で、送って消すまで `~/eyecam/contrib_…/` に残ります。始める前に何を録ってどう使うかを見せ、送りやすいように 1 つのファイルにまとめられます)。「ファイル」: ファイルの場所、本体の PID、コマンドで固定中の項目 - 記録: パネルから始めた目のカメラの校正と、目合わせ(被ったときに自分で始めた合わせ直しも)のたびに、`~/.local/state/frameeyeosc/reports/` に小さなフォルダを 1 つ残します(新しい 10 件だけ): `summary.json`(何をして、どう終わり、なぜか。版、診断コード、条件: ダッシュボードを開いていたか、カメラと視線トラッカーの速さ、かぶってから何秒か)、`status.jsonl`(その間 1 秒ごとの本体と eyecam の状態)、`logs.txt`(始まる 15 秒前から終わりまでの、本体・パネル・eyecam のジャーナルと Valve のアイトラッカーのログを時刻順に。300 KB まで)、校正なら `calib_result.json`(その数値。目の画像は入れません)、`report.txt`。「困ったとき」に新しい 3 件([すべての記録]で全部)、[見る]で理由・流れ・ファイルを開きます。目合わせや校正が失敗した画面にも「ログを記録したよ」と[記録を見る]が出ます。SSH からは `.local/bin/frameeyeosc-panel --report latest` でいちばん新しい記録の `report.txt` が出ます(`--report list` で一覧、`--report <フォルダ名>` でその記録。SteamVR が無くても動きます)。どこにも送りません パネルは `config.json` を書き、状態ファイルを読みます。既定の言語を決めるために、起動時に 1 回だけ Steam の `~/.steam/registry.vdf` の `language` の行も読みます(読むだけ)。更新には `~/.local/share/frameeyeosc/frame-update.sh` を使います(上を参照)。閉じても、終了しても、入れていなくても frameeyeosc は送り続けます。ダッシュボードで開いていない間は何も描きません。そのとき読むのは、更新の確認を動かすほかは、更新の状態ファイル(`~/.cache/frameeyeosc/update-state.json`)を 1 秒に 2 回ほどだけです。ただし目を合わせている間は、終わるまで状態ファイルも読み、点を出します(ダッシュボードが開いていても閉じていても)。デバッグ用の視線の点をオンにしている間も、そのソケットで受け取り、サンプルが届いている間は 1 秒に約 90 回点を動かします。このときは状態ファイルも読み、`config.json` が変わったかを 1 秒に 10 回確かめます。「被ったとき」が「何もしない」以外で視線を合わせてあるときも、ヘッドセットを被ったのに気づくため、ダッシュボードを閉じている間 0.5 秒ごとに状態ファイルを読みます。目のプレビューを開いている間は、視線の点のソケットで受け取り(点は出しません)、1 秒に約 30 回描き直します。カメラ映像がオンなら、eyecam に 1 秒ごとに映像を頼み、それを読みます。「SteamVR と一緒に起動」は、パネルの systemd ユーザーサービス(`frameeyeosc-panel.service`)を有効 / 無効にします。ビルド方法や確認用のオプションは [panel/README.md](panel/README.md) にあります。 @@ -499,7 +499,7 @@ eyecam-rec は、受け取ったバッファも Valve の目のデータも読 - パネルが `$XDG_RUNTIME_DIR/frameeyeosc/` に書くもの(メモリの上にあり、再起動で消える): 目を合わせるときの音のファイル `sounds/`(起動時に書く) - パネルが `~/.local/state/frameeyeosc/` に書くもの: 校正と目合わせの記録 `reports/`(新しい 10 件。[パネル](#パネル)を参照)と、`panel.json`(詳細タブ・まぶたタブ・視線タブで最後に開いたページ、視線の倍率の鍵、直近 5 回の全部合わせの上の幅)。記録を作るときは、その前後のジャーナル(`journalctl --user`、読むだけ)と Valve の `~/.local/share/Steam/logs/eyetracking.txt` を読みます - - eyecam(目のカメラ)が書くもの: 設定と校正 `~/.config/eyecam/`(`settings.json` に見開きの感度、`calib.json` に校正で測った値)、メモリの上(再起動で消える)の決まった場所: `/run/user/1000/eyecam/` に状態ファイル・操作用のソケット・frameeyeosc が読む目の値(と、パネルの目のプレビューでカメラ映像を出している間だけ、その映像。下を参照)、そのフォルダーの隣に eyecam-grab がバッファを渡すソケット `/run/user/1000/eyecam.sock`。systemd のジャーナル(`journalctl --user -u eyecam`)に、30 秒ごとに 1 行(処理したフレームの数、1 フレームの時間、左右の瞳孔のふだんの差)と、校正のたびに測った値の報告。校正では目の映像を残しません(開発者モードを除く)。校正のたびに(準備のときもあとからも、失敗したり止めたりしても)、`~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/` に小さなテキストのファイルだけ(1 MB ほど: 結果、フレームごとに出した値、フレームの時刻、Valve の値)を残します。eyecam の開発者モード(`~/.config/eyecam/settings.json` の `"dev": true`。手で書き換えるもので、最初はオフ)のときだけ、その 18 秒の目のカメラの映像も残します(1 回で約 0.5 GB)。詳細タブの開発用の「目の撮影」は、いつも目の映像を `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` に残します(1 回で約 2.5 GB。始める前にパネルにも出ます)。目のカメラを使っていてパネルの目のログを記録している間([開発](#開発)を参照)と、自分で eyecam に `driftlog on` を送ったとき(調べもの用)だけ、記録が終わるか `driftlog off` まで、数字だけ(映像なし)を `~/eyecam/drift/` に書きます(目のログと一緒なら `eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv`、手で送ったら `drift_YYYY-MM-DD_HH-MM-SS.csv`): 目ごとに 1 秒 1 行(瞳を見つけた位置、比べている基準の値、出した値、Valve の開き具合と視線の上下。1 時間で約 1.3 MB、1 ファイル 50 MB まで)。どれもヘッドセットの外には出しません。要らなくなったら消してください + - eyecam(目のカメラ)が書くもの: 設定と校正 `~/.config/eyecam/`(`settings.json` に見開きの感度、`calib.json` に校正で測った値)、メモリの上(再起動で消える)の決まった場所: `/run/user/1000/eyecam/` に状態ファイル・操作用のソケット・frameeyeosc が読む目の値(と、パネルの目のプレビューでカメラ映像を出している間だけ、その映像。下を参照)、そのフォルダーの隣に eyecam-grab がバッファを渡すソケット `/run/user/1000/eyecam.sock`。systemd のジャーナル(`journalctl --user -u eyecam`)に、30 秒ごとに 1 行(処理したフレームの数、1 フレームの時間、左右の瞳孔のふだんの差)と、校正のたびに測った値の報告。校正では目の映像を残しません(開発者モードを除く)。校正のたびに(準備のときもあとからも、失敗したり止めたりしても)、`~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/` に小さなテキストのファイルだけ(1 MB ほど: 結果、フレームごとに出した値、フレームの時刻、Valve の値)を残します。eyecam の開発者モード(`~/.config/eyecam/settings.json` の `"dev": true`。手で書き換えるもので、最初はオフ)のときだけ、その 18 秒の目のカメラの映像も残します(1 回で約 0.5 GB)。詳細タブの開発用の「目の撮影」は、いつも目の映像を `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` に残します(1 回で約 2.5 GB。始める前にパネルにも出ます)。詳細タブの「AI の学習に協力する」は、最初の画面で同意したときだけ、録画(目の映像。約 10.5 GB)と `info.txt`(アプリの版・時刻・プロトコル・フォルダ名・大きさ。個人の情報は入れない)を `~/eyecam/contrib_YYYY-MM-DD_HH-MM-SS/` に、まとめた 1 つのファイルも同じ場所に残します。パネルはどれもどこにも送りません(ファイルを渡すかはあなたが決めます)。[この録画を消す]でフォルダごと消せます。目のカメラを使っていてパネルの目のログを記録している間([開発](#開発)を参照)と、自分で eyecam に `driftlog on` を送ったとき(調べもの用)だけ、記録が終わるか `driftlog off` まで、数字だけ(映像なし)を `~/eyecam/drift/` に書きます(目のログと一緒なら `eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv`、手で送ったら `drift_YYYY-MM-DD_HH-MM-SS.csv`): 目ごとに 1 秒 1 行(瞳を見つけた位置、比べている基準の値、出した値、Valve の開き具合と視線の上下。1 時間で約 1.3 MB、1 ファイル 50 MB まで)。どれもヘッドセットの外には出しません。要らなくなったら消してください 上の eyecam の校正のファイル・録画・ドリフトの記録・ジャーナルの行と、パネルの記録(その `status.jsonl` には、校正や目合わせの間 1 秒ごとの本体と eyecam の状態が目の値ごと入ります)のほかは、目のデータは保存しません。ただし目を合わせたときに測った値(視線の平均の向きとばらつき、目ごとの開き具合の平均)は、1 回ごとに 1 行 systemd のジャーナルに残り、まぶたの読んだ値は `config.json` に残ります。最新の目の値は状態ファイルにありますが、これはメモリの上にあって本人しか読めず、1 秒に 10 回上書きされます。履歴は残しません - OSC は暗号化されないので、同じネットワーク上の他の機器から読める可能性があります diff --git a/README.md b/README.md index eda2cb3..ba5beba 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ To go back, turn "Get test versions" off. Your test version stays installed unti - Eye fit: one button that fits straight ahead, the up range and the eyelids in about 20 seconds (see [Eye fit](#eye-fit)), "Re-center only" (the one dot in the middle), what to fit by itself when you put the headset on ("When put on": nothing or re-center), the result with "Reset", and the values by hand under "Fine-tune" (while it is folded, a box says how fitting works). - Eyelids: four pages under a switch at the top (the first time "Look", then the one you opened last, also after the panel restarts). "Look": where the eyelids come from now (the eye cameras, one eye camera and Valve's values, or Valve's values; the cameras are switched on the Basic tab), "Widening" On / Off (`widen`; Off: no eyelid goes above relaxed open, with the eye cameras or without, while blinks and squint stay; the slider below is greyed meanwhile), "How easily widening shows" while the eye cameras are in use (one slider from Dull to Sensitive: for eyes on the cameras it sets their widening sensitivity; for an eye on Valve's values it picks Off / Less / Normal / More (`lid_widen`) for eyes with an eye fit), "Half-shut check (AI)" On / Off while the eye cameras are in use (eyecam's eyelid veto, off by default: a small AI double-checks an open eye read as half shut, for example looking to the side, and keeps it open; it may also open up a light squint; experimental, trained on the developer's eyes only), eyelid smoothing (light / medium / strong), and how strongly both lids are synced (`lid_sync`). "Blinks & squint": making blinks visible (hold time), blinking both eyes together (`blink_sync_below`), and, while the eye cameras are in use, the lowest eyelid when narrowed (`camera_lid_floor`; it takes effect while an eye is on the cameras). "Fine-tune": the auto calibration and its learned values, per-eye scales, the four openness marks drawn over each eye's live openness (blink and open wide to set them; 3 and 4 greyed for fitted eyes and eyes on the cameras), "Treat nearly open as open" (`lid_open_snap`), and the two smoothing values. "Brows": "Widen raises brows" On / Off (`brow_from_widen`, off at first; the cameras don't see brows, so the avatar's brows follow the widening, also with "Widening" Off) and, while it is on, "Brow raise strength" (`brow_strength`, Weak to Strong). - Eye cameras: shown while eyecam runs. Before the setup has begun, what the cameras add and "Get started (about 2 min)", which opens the setup checklist (see [Eye cameras](#eye-cameras)); once set up, what drives the eyelids now (switched on or off on the Basic tab) with "Relearn my eyes" under it (learns your relaxed eyes again in about 30 s, keeping the calibration; the eyelids, widening and squint keep working from the old values meanwhile, then move over to the new ones), "Calibrate the cameras (18 s)" for when something feels off, the optional "User calibration (once)" for squints, and what to do when. -- Advanced: four pages under a switch at the top, "Version", "Having trouble", "Debug tools" and "Files" (the first time "Version", then the one you opened last, also after the panel restarts). "Version": the version with checking for and installing updates (and the automatic check on or off) and "Version history" (each version's summary and changes, newest first; one opens at a time, and the thumbstick or the up / down chevrons on the right scroll). "Having trouble": the Diagnostics page to send as a screenshot when stuck (see [Troubleshooting](#troubleshooting)), and the recent records of calibrations and eye fits (see below). "Debug tools": showing gaze dots and their distance, recording the eye log (see below), and while eyecam runs the "Developer" eye-camera recording for tuning the eye processing, which keeps the eye video in `~/eyecam/rec_…/` (about 2.5 GB each time; see [panel/README.md](panel/README.md)). "Files": file locations, frameeyeosc's PID, options locked by the command line. +- Advanced: four pages under a switch at the top, "Version", "Having trouble", "Debug tools" and "Files" (the first time "Version", then the one you opened last, also after the panel restarts). "Version": the version with checking for and installing updates (and the automatic check on or off) and "Version history" (each version's summary and changes, newest first; one opens at a time, and the thumbstick or the up / down chevrons on the right scroll). "Having trouble": the Diagnostics page to send as a screenshot when stuck (see [Troubleshooting](#troubleshooting)), and the recent records of calibrations and eye fits (see below). "Debug tools": showing gaze dots and their distance, recording the eye log (see below), and while eyecam runs the "Developer" eye-camera recording for tuning the eye processing, which keeps the eye video in `~/eyecam/rec_…/` (about 2.5 GB each time; see [panel/README.md](panel/README.md)), and, once the eye cameras are set up, "Help train the AI": optional recordings of your eyes for the developer, to train the "Half-shut check (AI)" on more people's eyes (three guided recordings of about a minute, each after taking the headset off and putting it back on, and three minutes of normal use; about 10.5 GB, kept in `~/eyecam/contrib_…/` until you send it and delete it). It says what is recorded and how it is used before anything starts, and can pack it into one file to share. "Files": file locations, frameeyeosc's PID, options locked by the command line. - Records: every eye-camera calibration started from the panel and every eye fit (also the re-center run by itself when the headset is put on) leaves a small folder in `~/.local/state/frameeyeosc/reports/` (the newest 10 are kept): `summary.json` (what ran, how it ended and why, the versions, the diagnostic code, the conditions: dashboard open or closed, camera and eye-tracker rates, how long since the headset was put on), `status.jsonl` (frameeyeosc's and eyecam's status once a second while it ran), `logs.txt` (frameeyeosc's, the panel's and eyecam's journal and Valve's eye-tracker log from 15 seconds before it to its end, in time order, at most 300 KB), for calibrations `calib_result.json` (its numbers, never eye images) and `report.txt`. "Having trouble" lists the newest three ("All records" lists them all); "View" opens one: the reason, what happened in order, and the files. A failed fit or calibration says its log was saved, with "View record". Over SSH, `.local/bin/frameeyeosc-panel --report latest` prints the newest record's `report.txt` (`--report list` lists them, `--report ` prints one); it works without SteamVR. Nothing is sent anywhere. The panel writes `config.json` and reads the status file. To pick its default language, it also reads the `language` line of Steam's `~/.steam/registry.vdf` once at startup (read only). For updates it runs `~/.local/share/frameeyeosc/frame-update.sh` (see above). Closing it, quitting it, or not installing it doesn't stop frameeyeosc. While it isn't open on the dashboard it draws nothing. Besides running the update check, the only thing it reads then is the update state file (`~/.cache/frameeyeosc/update-state.json`), about twice a second. The exceptions are an eye fit (it then also reads the status file and shows the dot, with the dashboard open or closed, until the fit is over) and the debug gaze dots while they're switched on (it then listens on their socket and moves the dots about 90 times a second while samples arrive, and reads the status file and checks `config.json` for changes 10 times a second), and "When put on" while the gaze is fitted (it then reads the status file every 0.5 s while the dashboard is closed, to notice the headset being put on). While the eye preview is open it listens on the gaze dots' socket (without showing the dots) and redraws about 30 times a second; with its camera picture on, it asks eyecam for the pictures every second and reads them. Its "Start with SteamVR" switch enables or disables its systemd user unit (`frameeyeosc-panel.service`). Build notes and debugging options are in [panel/README.md](panel/README.md) (Japanese). @@ -499,7 +499,7 @@ To update again later, use the panel's update button or the usual install comman - from the panel, in `$XDG_RUNTIME_DIR/frameeyeosc/` (in memory, gone after a reboot): the eye fit's sound files in `sounds/`, written at start - from the panel, in `~/.local/state/frameeyeosc/`: the records of calibrations and eye fits in `reports/` (the newest 10; see [Panel](#panel)) and in `panel.json` the last page of the Advanced, Eyelids and Gaze tabs, the gaze scale's locks and the up ranges of the last 5 full eye fits. To make a record the panel reads the journal (`journalctl --user`, read only) and Valve's `~/.local/share/Steam/logs/eyetracking.txt` for the minute around it - - from eyecam (the eye cameras): its settings and calibration in `~/.config/eyecam/` (`settings.json` with the widening sensitivity, `calib.json` with what the calibrations measured), and in memory (gone after a reboot) at fixed paths: `/run/user/1000/eyecam/` with its status file, its control socket and the eye values frameeyeosc reads (and, only while the panel's eye preview shows the camera picture, the preview's pictures: see below), and next to that folder `/run/user/1000/eyecam.sock`, the socket eyecam-grab hands the buffers to. In the systemd journal (`journalctl --user -u eyecam`), every 30 seconds one line with how many frames it worked out, how long each took and the usual difference between the left and right pupil, and after each calibration a report of what it measured. Calibrations keep no eye video: each one (the setup's and later ones, also one that failed or was stopped) leaves only small text files in `~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/`, about 1 MB: the result, the values worked out for each frame, the frame times and Valve's readings. Only in eyecam's developer mode (`"dev": true` in `~/.config/eyecam/settings.json`, set by hand; off by default) do they keep the 18 seconds of eye-camera video too (about 0.5 GB each). The developer eye recording on the Advanced tab always keeps eye video, in `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` (about 2.5 GB each time); the panel says so before it starts. While the panel's eye log records with the eye cameras in use (see [Development](#development)), and if you send eyecam `driftlog on` yourself (an investigation tool), it writes numbers only, no pictures, to `~/eyecam/drift/` (`eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv` beside the eye log, `drift_YYYY-MM-DD_HH-MM-SS.csv` by hand): one line per eye per second (where it found the pupil, the levels it compares with, the values it worked out and Valve's openness and pitch; about 1.3 MB an hour, at most 50 MB a file) until the recording ends or `driftlog off`. Nothing of it leaves the headset; delete them when you no longer need them + - from eyecam (the eye cameras): its settings and calibration in `~/.config/eyecam/` (`settings.json` with the widening sensitivity, `calib.json` with what the calibrations measured), and in memory (gone after a reboot) at fixed paths: `/run/user/1000/eyecam/` with its status file, its control socket and the eye values frameeyeosc reads (and, only while the panel's eye preview shows the camera picture, the preview's pictures: see below), and next to that folder `/run/user/1000/eyecam.sock`, the socket eyecam-grab hands the buffers to. In the systemd journal (`journalctl --user -u eyecam`), every 30 seconds one line with how many frames it worked out, how long each took and the usual difference between the left and right pupil, and after each calibration a report of what it measured. Calibrations keep no eye video: each one (the setup's and later ones, also one that failed or was stopped) leaves only small text files in `~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/`, about 1 MB: the result, the values worked out for each frame, the frame times and Valve's readings. Only in eyecam's developer mode (`"dev": true` in `~/.config/eyecam/settings.json`, set by hand; off by default) do they keep the 18 seconds of eye-camera video too (about 0.5 GB each). The developer eye recording on the Advanced tab always keeps eye video, in `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` (about 2.5 GB each time); the panel says so before it starts. "Help train the AI" on the Advanced tab, only after you agree on its first screen, keeps its recordings (eye video, about 10.5 GB) with an `info.txt` (the app version, times, protocols, folder names and sizes; nothing personal) in `~/eyecam/contrib_YYYY-MM-DD_HH-MM-SS/`, and the one file it packs them into there too; the panel sends none of it anywhere (sharing the file is up to you), and its "Delete these recordings" removes the folder. While the panel's eye log records with the eye cameras in use (see [Development](#development)), and if you send eyecam `driftlog on` yourself (an investigation tool), it writes numbers only, no pictures, to `~/eyecam/drift/` (`eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv` beside the eye log, `drift_YYYY-MM-DD_HH-MM-SS.csv` by hand): one line per eye per second (where it found the pupil, the levels it compares with, the values it worked out and Valve's openness and pitch; about 1.3 MB an hour, at most 50 MB a file) until the recording ends or `driftlog off`. Nothing of it leaves the headset; delete them when you no longer need them Apart from eyecam's calibration files, recordings, drift logs and journal lines above, and the panel's records (their `status.jsonl` keeps frameeyeosc's and eyecam's status, with the eye values, once a second while a calibration or fit ran), no eye data is stored, except that each eye fit measurement (an average gaze direction, how much it spread, and each eye's average openness) is logged as one line to the systemd journal, and the eyelid readings of a fit are kept in `config.json`. The latest eye values are in the status file, which is in memory, readable only by you, and overwritten ten times a second; no history is kept. - The OSC messages are unencrypted, so other devices on the same network could read them. diff --git a/panel/README.md b/panel/README.md index cb1deea..b94c5f2 100644 --- a/panel/README.md +++ b/panel/README.md @@ -31,7 +31,7 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ | 目を合わせる | [目を合わせる](ヒント「正面・上の幅・まぶた・約 20 秒」。合わせたあとは[もう一度合わせる])と小さい[真ん中だけ合わせ直す]、「被ったとき」の行(何もしない / 真ん中だけ、`auto_recenter`。既定は真ん中だけ。0.7.6 までの `"tilt"` は `"center"` として読む)、今どうなっているか(始め方・結果[元に戻す]・止めた / 失敗の理由。被ったときの合わせ直しをダッシュボードが止めて、もう一度やるときは「ダッシュボードを閉じたら、もう一度合わせ直します」も)。目のカメラの校正中(`eyecam::calibrationUnderWay`: eyecam-rec が `calibrating`、目の映像を待っている間も。パネルが送って返事を待っている間も)は[目を合わせる]と[真ん中だけ合わせ直す]がグレーで、箱の見出しが「目のカメラの校正が終わってから使えます」。目を合わせている間はタブ全体が「点を見てください」と何をするか(動かない文字)と大きな[やめる]だけになり、左の列とタブの並びは暗くなる(押せるのはタブと[やめる]だけ。ほかのタブを押すと止まる)、[♪ 音を鳴らす: オン / オフ]、「細かく直す」(開閉。[視線 / まぶた]で切り替え。視線: 正面の位置・動く幅(左右・上・下)、真下で左右を止める角度。まぶた: 目ごとの読んだ値 閉じ / 上 / 正面 / 下 の − / +。閉じているあいだは、その下に「合わせ方」の箱: 被るたびは真ん中の点を 1 つ見るだけ(自動)、[目を合わせる]は最初と気になったときだけ、傾きは使わなくなったこと) | | まぶた | 見た目の調整を全部ここに(案E)。いちばん上の切り替えで 4 つの小タブ(詳細タブと同じ形。初めては「見た目」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `lids_page` で覚えている)。「見た目」: 「いまのまぶた」の帯(目のカメラ(左・右)/ 目のカメラ(左)+ Valve(右)/ 目のカメラ(待機中)/ Valve の値。準備が済んでいれば右に「オン・オフは「基本」タブで」)、「見開き」の行(する / しない で `widen` を `SetBool`。カメラを使っていてもいなくてもいつも出す。ヒント「閉じ・細めはそのまま」。「しない」のあいだは下の「見開きの出やすさ」を灰色にして押せなくし、その下の 1 行を「「見開き」が「しない」なので、いまは使わないよ」に。目のカメラタブのいちばん下の文も「カメラで両目を見て、まぶたを送ってるよ(見開きはしない)」に、診断の「まぶた」の行には「· 見開きしない」を足す。`--fake-no-widen` で描ける)、目のカメラを使っているときだけ「見開きの出やすさ」のスライダー 1 本(緑の「カメラ」バッジ。鈍い ↔ 敏感。両目がカメラなら eyecam-rec の `widen_sensitivity` を `set widen_sensitivity` で送る。片目だけカメラなら同じく感度を送り、Valve の目には近い `lid_widen` の段階も一緒に書く。Valve の値なら `lid_widen` の 4 段階(しない / 控えめ / ふつう / 出やすい)で、押した所に近い止まり位置へ。目を合わせていない目があると下に「目を合わせていない目は、「細かく直す」の目盛りで決まるよ」。いまカメラの目が無く、ふつうに開いた目が 1.0 と読まれる SteamOS(`openness_saturated`)では、スライダーの代わりに 1 行: ヒント「この SteamOS では効かないよ」、「Valve の値だけだと見開きが出ないよ」と[目のカメラへ →](目のカメラタブがあるときだけ))、目のカメラを使っていて eyecam-rec の status.json に `lid_veto.on` があるときだけ「半目の読み直し(AI)」(する / しない。緑の「カメラ」バッジ。eyecam-rec に `set lid_veto on|off` を送り、選ばれているほうは status.json の `lid_veto.on` のまま〔panel.json には持たない〕。下に 3 行までの説明。`--fake-eyecam …:veto=on|off` で描ける)、まぶたのなめらかさ(弱 / 中 / 強。中 = 本体の既定値、どれにも合わなければカスタム)、左右をそろえる強さ(`lid_sync` のスライダー、0.05 刻み)。目のカメラを使っていないときは、いちばん下に点線の 1 行「目のカメラを使うと「見開きの出やすさ」が増えるよ」(押すと目のカメラタブ)。なので、カメラが無いと `lid_widen` はパネルから変えられない。「まばたき・細め」: まばたきを届ける(保持 ms の − / +)、まばたきを両目でそろえる(`blink_sync_below` の − / +、0 でオフ)、細めたときのまぶたの下限(`camera_lid_floor` の − / +、0.05 刻み、0 でオフ。目のカメラを使っているときだけ出す。緑の「カメラ」バッジ。カメラの目がまだないときは「目のカメラが目を見てるときに効くよ」と添える。使っていないときは点線の 1 行「目のカメラを使うと「細めたときのまぶたの下限」も使えるよ」)。それぞれ右に説明。「細かく直す」: いちばん上に目盛りの決まり方の 1 行、自動キャリブレーション(目を合わせていない目で、カメラでないとき)、左右の倍率、生の値の棒と 4 つの目盛り(③④は目を合わせた目とカメラのときは灰色)、ほぼ開いてたら全開にする(⑤。生の値の棒にも ⑤ の青い破線を出す。目を合わせた目は倍率をかけた位置、カメラが読んでいる目は薄く、0.75 のときは出さない。行の数字と − / + の縁も同じ青。`lid_open_snap` の − / +、送るまぶたの値で 0〜0.75 の 0.01 刻み、0.75 でオフ。いつも出す。右に説明。開いた値に対する割合だったころの 0.75 より大きい値は、起動時に一度だけ置き換える(`migrateLidOpenSnap`))、なめらかさの 2 つの値(止まっている時・速い動き。ページに収まるよう、それぞれの左に名前を置いた 1 行)。目のカメラタブの[まぶたへ →]は「見た目」を開く。「眉」: 「見開きで眉も上げる」(する / しない で `brow_from_widen` を `SetBool`。ヒント「カメラは眉を見ていないよ」。カメラを使っていてもいなくても、「見開き」が「しない」でもいつも出す。下に 3 行までの説明〔`\n` の所で改行〕)と、「する」のあいだだけ「眉の上がり方」(`brow_strength` のスライダー、弱い ↔ 強い、0.05 刻み。ヒント「アバターに合わせて」)。`--fake-brows` で「する」を描ける。`--lids-page look|blinks|fine|brows` で小タブを選んで描く(`--lid-marks` は fine と同じ) | | 目のカメラ | eyecam-rec が動いているあいだだけ出る(「詳細」の前。出る条件は下の「目の撮影」と同じ)。準備を始める前(準備が済んだことがなく、まだ ① か ② の段で、このパネルを起動してから[はじめる]を押していないとき。`eyecam::introShown`)は、「目のカメラでできること」(見開き・瞳孔の大きさ・Valve がまぶたを低く読んだときにカメラで確かめて直す)、1 回だけの準備と目の映像は Frame の外に出さないことの箱、[はじめる(約 2 分)]、「使わなくても、目の送信はふつうに動きます」。押したあと(と準備が進んでいるとき)は準備のチェックリスト(いまの段階は、満たしていない最初のもの。毎回 status.json とパスワードの確認だけで決める): ① パスワードを決める(SteamOS にパスワードが無いと分かっているときだけ。`steamos-passwd --has-password` を裏で数秒おきに確かめる。読むだけ。[Konsole で passwd を開く])② 道具を入れる([Konsole で開く]で `sudo $HOME/.local/lib/eyecam/install_grab.sh` が入力済みの Konsole を開くだけ。Enter とパスワードは本人で、パネルは sudo も passwd も実行しない。`auto_grab` が ok / trying / waiting_tracker / failed: … か、バッファを持っていれば入っている扱い。`auto_grab` が `too_old`(eyecam の安全のための下限より古く、起動されない)なら入っていない扱い。`grab_outdated` が true(更新で新しい道具が届いた)か too_old なら、見出しを「道具を入れ直す」、その下に「道具が新しくなったよ、入れ直してね」)③ 目の動きを覚える(カメラの映像は「届いてるよ」、`cam_fps` が毎秒 60 枚より少なければアクセント色で「届いてるよ(毎秒 15 枚・少なめ)」。目ごとに「見えてるよ」(status.json の `pupil_l` / `pupil_r` で瞳が 50 % 以上)・「映像はあるけど瞳が見つからない」(`locked` だけど 20 % 未満、赤)・「瞳がときどきしか見えない」(そのあいだ)・「見えてないよ」(`locked` が false)。`pupil_l` / `pupil_r` の無い古い eyecam-rec では今までどおり `locked` だけで決める。目の映像が届いているとき(`locked`)は[覚えはじめる]で `calib wear` を押せて、瞳が見つからない目があるときはその下に赤字で「HMD をかぶり直して」と出す。18 秒の間、ステップのチップ・残り秒・[やめる]をカードの中に出す。失敗したら eyecam-rec のメッセージ(数も入っている)の上に、メッセージが名前を出した目(「うまくいかなかった目」)を出す。見開きだけ取れなかったら[このまま進む][もう一度(18秒)]。片目だけ失敗して通ったとき(`calib_failed_eye` が L / R)は「左目は前の値を使うよ」(初めてなら「仮の値」)と目ごとの結果、メッセージ、[使いはじめる][もう一度(18秒)])④ 完了(「使いはじめる」)。セットアップ済みでも道具が無くなれば ② に戻り、直れば校正し直さずにふだんの画面へ。道具が古いだけなら ② には戻さず、ふだんの画面のいちばん上にお知らせのカードと[Konsole で開く](`grab_outdated`: 「道具が新しくなったよ、入れ直してね(今の道具のままでも動くよ)」、`too_old`: 赤いカードで「安全のため、道具を入れ直してね。入れ直すまでカメラは止まってて、まぶたは Valve の値だけで送ってるよ」)、左の列にも同じお知らせ(押すとこのタブ)。eyecam-rec が言わなくなれば自然に消える(`eyecam::toolState` / `toolNotice`。`--fake-eyecam …:outdated`、`waiting:auto=too_old:setup:outdated` で描く)。左の列には、準備が済むまで「次にやること・目のカメラ」、済んだ直後に緑の「目のカメラ:準備できたよ」。済んだあとのふだんの画面: いまの状態(両目・片目・Valve の値とその理由。ヒントは「オン・オフは「基本」タブで」。カメラのスイッチ `camera_lids` は「基本」タブに移した。オフのあいだの校正のボタンは「「基本」タブで目のカメラを「使う」にしてから」)、その下の行の右に[普段の目を覚え直す](英語は Relearn my eyes。左に「約 30 秒。そのあいだも今の値で動くよ」。eyecam-rec に `relearn` を送り、覚え始めがやり直しになって、いまの状態の右のピルが「普段の目を覚えてるよ(あと N 秒)」に戻る。覚えているあいだも、もう覚えていた目は前の値でまぶた・見開き・細めを送り続ける(`calib_state` の 4 はそのまま)。押せるのは `eyecam::relearnAllowed`: eyecam-rec の状態が新しく、idle・live・目の映像あり〔`locked`〕・`baseline` を書く eyecam-rec、`camera_lids` がオン、カメラがまぶたを動かしているか覚えている途中〔`relearnUse`〕、ほかのコマンドの返事待ちでない)、違和感があるときの[目のカメラの校正(18秒)]、細めも送るときの[ユーザー校正(最初に 1 回)](どちらも ③ と同じカードで進み、終わると結果のカード)、こんなときは。見開きの出やすさは「まぶた」タブ。`--fake-eyecam` と `--fake-password`、`--fake-camera` で描く | -| 詳細 | いちばん上の切り替え(バージョン / 困ったとき / 動作確認 / ファイル。英語は Version / Having trouble / Debug tools / Files)で 1 ページずつ出す。初めて開いたときは「バージョン」、そのあとは最後に開いた小タブ(パネルを起動し直しても。下の「最後に開いた小タブ」)。ほかのタブから戻っても同じ小タブ。新しい版があるあいだは「バージョン」の後ろに小さな点。「バージョン」: 今の版(大きく)と、最後に確かめた時刻・結果(「21:10 に確認・新しい版 0.7.6 があります」。確かめられなかった・更新に失敗したときは赤で、理由は 4 行、くわしくの場所は 2 行まで)。新しい版があれば右に[0.7.6 に更新する](確認を 1 回はさむ)、その下の箱に「0.7.6 の内容」としてその版の要約を 4 行まで(日本語の画面ではリリースの「日本語:」の段落、無ければ英語。要約の無いリリースなら箱を出さない)。更新に失敗したら[もう一度][閉じる]、入れ終わったら[閉じる]。いちばん下の行に[更新履歴](下を参照)、「起動時と 1 時間ごとに確認 オン / オフ」のチップ(押すと切り替わる)、右に[今すぐ確かめる](確かめている間と更新中は押せない)。カードのいちばん下に「ささけん@の Frame アプリ」の行(下の「ほかの Frame アプリ」)。「困ったとき」: 「診断」の行(「困ったら、診断の画面のスクショを送ってね」、診断コードのチップ、[開く]で下の「診断」ページ)、「最近の記録」(新しい 3 件。時刻・何をしたか・短い理由・OK / 失敗 / 片目だけ・[見る]。下の「記録」)、「SSH なら frameeyeosc-panel --report latest」と[すべての記録(N)›]。「動作確認」: 視線の点を表示(デバッグ用、オン / オフ)と点の距離(0.3〜2.0 m、0.1 m 刻み。1.2 m くらいより奥だと開いたダッシュボードに隠れる。点がオフの間は灰色)、そのすぐ下の行に「倍率の前の向き(輪)」(ヒント「目が見ている向き」。英語は Ring: before the scale / Where your eyes look。オン / オフは点のスイッチと同じ幅で真下に並び、右に「視線の倍率が 100 % でないときだけ、点のまわりに輪で出ます」〔英語は Shown around the dot only while a gaze scale isn't 100 %〕を 2 行まで。点がオフの間は選んでいる側を枠だけにして灰色、押せない)、目のログ(ボタンの右に保存先と 60 分の上限、目のカメラを使っているときは「目のカメラの数字(映像なし)も ~/eyecam/drift/ に残します」も。記録できなかったらその理由を 3 行まで。目のカメラを使っていて eyecam-rec が driftlog を持っていれば、記録の間 eyecam のドリフトの記録も取る: 始めに `driftlog on eyes_日時`、終わりに `driftlog off`。記録の途中で eyecam-rec が起き直してドリフトの記録が止まったら、もう一度 `driftlog on`〔断られたら 30 秒おき〕。recorder.h の DriftLink)、eyecam-rec が動いているあいだは「開発用」のバッジ付きの「目の撮影」([撮影開始]で下の光の注意。ボタンの右に「目の映像が ~/eyecam/rec_日時/ に残るよ(1 回で約 2.5 GB)」)。「ファイル」: 設定ファイル・キャリブレーション・状態ファイル・記録の場所、本体の PID と動いている時間、コマンドで固定中の項目と今の値(3 行まで) | +| 詳細 | いちばん上の切り替え(バージョン / 困ったとき / 動作確認 / ファイル。英語は Version / Having trouble / Debug tools / Files)で 1 ページずつ出す。初めて開いたときは「バージョン」、そのあとは最後に開いた小タブ(パネルを起動し直しても。下の「最後に開いた小タブ」)。ほかのタブから戻っても同じ小タブ。新しい版があるあいだは「バージョン」の後ろに小さな点。「バージョン」: 今の版(大きく)と、最後に確かめた時刻・結果(「21:10 に確認・新しい版 0.7.6 があります」。確かめられなかった・更新に失敗したときは赤で、理由は 4 行、くわしくの場所は 2 行まで)。新しい版があれば右に[0.7.6 に更新する](確認を 1 回はさむ)、その下の箱に「0.7.6 の内容」としてその版の要約を 4 行まで(日本語の画面ではリリースの「日本語:」の段落、無ければ英語。要約の無いリリースなら箱を出さない)。更新に失敗したら[もう一度][閉じる]、入れ終わったら[閉じる]。いちばん下の行に[更新履歴](下を参照)、「起動時と 1 時間ごとに確認 オン / オフ」のチップ(押すと切り替わる)、右に[今すぐ確かめる](確かめている間と更新中は押せない)。カードのいちばん下に「ささけん@の Frame アプリ」の行(下の「ほかの Frame アプリ」)。「困ったとき」: 「診断」の行(「困ったら、診断の画面のスクショを送ってね」、診断コードのチップ、[開く]で下の「診断」ページ)、「最近の記録」(新しい 3 件。時刻・何をしたか・短い理由・OK / 失敗 / 片目だけ・[見る]。下の「記録」)、「SSH なら frameeyeosc-panel --report latest」と[すべての記録(N)›]。「動作確認」: 視線の点を表示(デバッグ用、オン / オフ)と点の距離(0.3〜2.0 m、0.1 m 刻み。1.2 m くらいより奥だと開いたダッシュボードに隠れる。点がオフの間は灰色)、そのすぐ下の行に「倍率の前の向き(輪)」(ヒント「目が見ている向き」。英語は Ring: before the scale / Where your eyes look。オン / オフは点のスイッチと同じ幅で真下に並び、右に「視線の倍率が 100 % でないときだけ、点のまわりに輪で出ます」〔英語は Shown around the dot only while a gaze scale isn't 100 %〕を 2 行まで。点がオフの間は選んでいる側を枠だけにして灰色、押せない)、目のログ(ボタンの右に保存先と 60 分の上限、目のカメラを使っているときは「目のカメラの数字(映像なし)も ~/eyecam/drift/ に残します」も。記録できなかったらその理由を 3 行まで。目のカメラを使っていて eyecam-rec が driftlog を持っていれば、記録の間 eyecam のドリフトの記録も取る: 始めに `driftlog on eyes_日時`、終わりに `driftlog off`。記録の途中で eyecam-rec が起き直してドリフトの記録が止まったら、もう一度 `driftlog on`〔断られたら 30 秒おき〕。recorder.h の DriftLink)、eyecam-rec が動いているあいだは「開発用」のバッジ付きの「目の撮影」([撮影開始]で下の光の注意。ボタンの右に「目の映像が ~/eyecam/rec_日時/ に残るよ(1 回で約 2.5 GB)」)、その下に「AI の学習に協力する」(ヒント「半目の読み直し(AI)のための録画」、[開く]。目のカメラの準備が済むまでは押せず、右に「目のカメラの準備が済むと使えるよ」。準備のあとは右に、始める前は「目の映像を録って開発者に送る手伝い(任意・約 10.5 GB)」、途中は「1 / 4 回録れたよ。続きからできるよ」、録り終わったら「全部録れたよ。まだなら開発者に送ってね」。下の「AI の学習に協力する」)。「ファイル」: 設定ファイル・キャリブレーション・状態ファイル・記録の場所、本体の PID と動いている時間、コマンドで固定中の項目と今の値(3 行まで) | - 目のプレビュー(`eye_preview.{h,cpp}`): 左の列の「目のプレビュー」で、タブの中身の上にシートを開く(ほかの Frame アプリの一覧と同じ枠と ✕。タブは並んだままで、どれも選ばれていない見た目になり、どのタブを押しても閉じる。左の列のお知らせを押しても、ダッシュボードを閉じても閉じる)。`--preview-sheet` で開いて描く - 上に「目のプレビュー」と ✕。その下の 1 行: 「送る値 / 生の値 / カメラの値」(どの値で目を描くか)、「鏡で見る」(既定オン: 鏡に映した向き。右目を右、左目を左に描き、右を見ると虹彩も右へ。オフでほかの人〔正面のアバター〕から見た向き: 右目を左に描き、左右の視線を反転)、「カメラ映像」(最初はオフ。うっかり目が映らないように。一度オンにすると、パネルが動いているあいだは開くたびにオンのまま。オフにするか、カメラが使えなくなると解除)、[止める]/[動かす](その時点の値と映像のまま止める)。スイッチは、オンで ✓ とアクセントの薄い塗り・太字。選んだものはパネルを起動しているあいだ覚えている(止める・動かすは開くたびに解除) @@ -203,6 +203,7 @@ ssh steamos@ 'cd ~/frameeyeosc-panel && cmake -G Ninja -S . -B bui ./build/text-test # テンキーで打った送り先の IP の確認と、目合わせの失敗の文(日本語・英語)のテスト ./build/sounds-test # 目合わせの音の WAV と、どの場面でどの音かのテスト(鳴らさない) ./build/changelog-test # 更新履歴の読み方(英語と日本語の形、「日本語:」を飛ばす、最初の文、マークダウン、英語で補う)のテスト +./build/contrib-test # 「AI の学習に協力する」(ステップと大きさ、eyecam のプロトコルのファイルとの照らし合わせ、info.txt、録画の終わりとかぶり直しの見分け、画面、tar の見出し、$TMPDIR の中で作る・移す・まとめて tar と zstd で戻す・消す、最初から最後まで)のテスト ./build/eyecam-test # 目の撮影タブ(状態ファイル、出す・消す条件、指示の文、全面の光とそのフェード、光の注意と送るコマンド、/tmp の偽ソケット)のテスト ./build/report-test # 記録(ジャーナルと Valve のログの読み方、主な行とその言葉、summary.json、report.txt、作り物のログからの記録、10 件、大きさの上限、--report)と最後の小タブのテスト ./build/file-io-test # ファイルをまとめて読む file_io(上限、NUL、4 KiB の境目、無いファイル)のテスト @@ -242,6 +243,7 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - `--report-make 種類 始め 終わり 結果 理由`(確かめるためのもの): その時間のジャーナルと Valve のログから、パネルが走り終わったあとに書くのと同じ記録を `--reports-dir` に書き、フォルダを出す(例: `--report-make fit "2026-10-06 20:45:09" "2026-10-06 20:45:17" failed "…"`。条件と診断コードは空) - 更新履歴は `--history` で開き、`--history-open 版` でその版の行を開き、`--history-scroll px` でスクロールして描く(どちらも `--history` を含む。スクロールは中身の範囲に収める)。変更履歴は上の場所から探す。`--changelog-dir DIR` でそのフォルダだけを見る(無いフォルダなら「見つかりません」の見た目)。`--fake` とも一緒に使える - `--click X,Y`(何回でも)は、描く前にその座標を押したことにする。当たり判定と設定ファイルの書き込みをヘッドセットなしで確かめる用(`--fake` とは一緒に使えない。`--config` の設定ファイルを本当に書き換えるので、試すときは別の場所を指定する) +- 「AI の学習に協力する」(下)は `--fake-contrib 状態` で、作り物の協力(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意の画面)・`nospace`(空きが足りない)・`deleted`(消したあとの同意の画面)・`rewear`(2 回目の前のかぶり直し)・`rewear-off`(外したのが分かった。[かぶり直した]が押せる)・`ready`(2 回目を始める)・`ready-free`(ふだん使いを始める)・`running`・`running-free`(録画中)・`failed`・`failed-error`(最後まで録れなかった)・`done`(録り終わり)・`packing`(まとめている 45 %)・`packed`(まとめた)・`extra`(任意のふだん使い)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」のその状態の行を描く。何も読み書きしない - 目の撮影タブ(下)は `--fake-eyecam waiting|idle|confirm|searching|recording:ラベル|error` と `--tab eyecam` で描く(`confirm` は idle で光の注意を開いたところ。`recording:ラベル:unlocked` でカメラが目を見失ったとき=「HMD をかぶってください」、`recording:ラベル:nolight` で光なしのプロトコル=「光なし」。`:pupil=0.97,0.04` で目ごとの瞳の見え具合(`null` も可。無ければ古い eyecam-rec)、`:failed=L|R|LR|mixed`(`:prov`)で片目だけ失敗した校正や両目の失敗のメッセージ。`:page=user:lookdown=L|R|LR` で、下を見たときの補正をその目(LR は両目)だけ標準の値にして通ったユーザー校正のカード、`:lookprev=L|R|LR` で前の値のままにして通ったカード(`lookdown=R:lookprev=L` で目ごとに違うとき)。`:search=not_worn|no_video|one_eye` で映像が見つからない理由(`status.json` の `search`。`not_worn` は近接センサー 12)。ラベルは lead_in・normal・widen・close・squint・look_up・look_down・bright・dark・end。recording は eyecam の `protocol_widen.txt`(17 ステップ・83 秒。`nolight` なら `protocol_widen_nolight.txt` の 14 ステップ)でそのラベルが最初に出るステップになり、その手順をステップのチップにも使う(ファイルは読まず、同じ手順を main.cpp に持っている)。error は失敗した返事も出す)。`--eyecam-fill-png PATH`(`--eyecam-fill bright|dark`、`--language`)で全面の光の画像。`--eyecam-dir DIR` で eyecam-rec のフォルダ(`status.json` と `ctl.sock`)を別の場所にする。`--fake` なしの `--dump-png` はそこを読み、`--click` で[撮影開始]→[光ありで始める]/[光なしで始める]や[中止]を押すとそのソケットに本当に送って返事を待つ(試すときは一時フォルダに偽の `status.json` とソケットを置く。本物の `/run/user/1000/eyecam/` には何も作らない) - `--probe` は Background 型でつなぐだけで、オーバーレイも Vulkan も作らない。`FindOverlay`・名前・幅・閉じるボタン・表示中か・`GetOverlayTextureSize` を出す - `contrib/icons/frameeyeosc-panel-{48,128,256}.png` は `--thumbnail-png` で書き出したもの(ダッシュボードのサムネイルと同じ絵) @@ -258,6 +260,25 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - **フェード**: 出るときは `SetOverlayAlpha` を 0 から 1 へ 0.7 秒かけて上げる(0 にしてから `ShowOverlay`)。録画が続いたままステップが変わったときは 0.5 秒かけて 0 へ下げてから隠す。明るい↔暗いが続くときも白から黒へ一気に変えず、今の光を 0.5 秒で消してから次の光を 0.7 秒で出す。アルファは前の周からの経過時間で動かす(`eyecam::stepLight`。1 回に進める時間は 0.1 秒まで、なのでループが詰まっても一気に明るくならない)。フェードの間はループを表示のフレームごと(約 90 Hz)に回す - **すぐ隠す**: 状態ファイルが 1 秒より古い・タブが消えた・録画していない(止まった・失敗した・idle に戻った)・[中止]を送った(返事を待たずに)のどれかなら、フェードせずにその周で隠す(`eyecam::hideLightAtOnce`)。パネルの終了時(どの終わり方でも)は真っ先に隠し、終了処理で `DestroyOverlay` する。プロセスが落ちたときは SteamVR が消す +### AI の学習に協力する(contrib) + +「半目の読み直し(AI)」(eyecam のまぶたの見直しの CNN)を、いろんな人の目で学習させるための録画を、パネルだけで最後まで進められるようにしたもの。開発者に送ってもらうのは、eyecam の光なしの撮り方 3 回(かぶるたびに 1 回)とふだん使い 3 分。録画そのものは「目の撮影」と同じく eyecam-rec が `~/eyecam/rec_日時/` に書き(`cues.csv`・`valve.csv`・`frames.csv`・`meta.txt` もそのまま)、終わったものをパネルが協力のフォルダに移す。eyecam-rec は変えていない(`start widen_nolight` と `start free` を送るだけ。どちらのプロトコルのファイルも install.sh が eyecam-rec の横に置く)。 + +- **入り口**: 「詳細」›「動作確認」の「目の撮影」の下の「AI の学習に協力する」の[開く](上の表)。目の撮影と同じく eyecam-rec が動いているあいだだけ出て、押せるのは目のカメラの準備が済んでから(`eyecam::setUp`)。「開発用」のバッジは付けない(使う人向け)。まぶたタブの「半目の読み直し(AI)」の行は使う人の設定なので、10 GB の録画の入り口はそこに置かない +- **シート**: 詳細タブの中身の代わりに出る(タブの列と左の列はそのまま。どのタブ・小タブを押しても閉じる。ダッシュボードを閉じても開いたまま)。上に「AI の学習に協力する」とヒント、右に × の[閉じる]。同意のあとは × の左に小さな[この録画を消す](かぶり直し・始める・録れなかった画面)、その下にステップのチップ(決まった動き 1〜3・ふだん使い。済んだもの: チェック、次: アクセント色。任意のふだん使いは頼んだか録ったときだけ「ふだん使い(追加)」)。画面は `contrib::screen` が決める: + - **同意**: 「「半目の読み直し(AI)」を、いろんな人の目で学習させるための録画だよ。協力するかどうかは自由です。」、左に見出しで「録るもの」(目のカメラの映像、約 1 分を 3 回とふだん使い 3 分。全部で約 10.5 GB〔目のカメラの毎秒の枚数から。分からなければ 90〕。送るまでは Frame の中だけ)・「使いみち」(半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く)・「消してほしいとき」(言えば消す。もう配った AI に入った分は取り出せない)。その下に灰色で「画面が白や黒に光る段はないよ(光なしで撮るよ)。全部で 15 分くらい。途中でやめても、あとで続きからできるよ。」(光の注意は要らない)、ボタンの上に「空き容量 23.2 GB(要るのは約 11.5 GB)」(録画の分と 1 GiB の余り。足りなければ赤で「空きが足りないよ。あと 4.1 GB 空けてね」で[協力する]は押せない)。[やめる](閉じる)と[協力する](アクセント)。消したあとは上に緑で「録画を消したよ」 + - **かぶり直し**(2 回目から、始める前): ステップの名前(「決まった動き(2 / 3)」「ふだん使い(3 分)」)、アクセントの枠の箱に大きく「HMD をいったん外して、もう一度かぶってね」、「かぶるたびにカメラの映り方が少し変わるのが、学習に大事なんだ。」、点と「かぶり直したら、またこの画面を開いてね。外したのも、かぶったのも自動で分かるよ。」(外したのが分かったら、アクセントの点と太字で「外したのが分かったよ。かぶって少し待ってね」)。右下に[かぶり直した]: 待ち始めて 20 秒(`kFallbackSec`)たってから押せる(それまで左に「かぶり直してから押せるようになるよ」、そのあと「自動で分からないときは、かぶり直してからこれを押してね」) + - **始める**: ステップの名前と、かぶり直しのあとなら緑のチェックで「かぶり直したのが分かったよ」。決まった動きは「ビープと画面の指示に合わせて、目を閉じる・普通・見開くを 3 回、そのあと細める・上を見る・下を見るをするよ。約 1 分。」と[撮影をはじめる(約 1 分)]。ふだん使いは点つきで「いつもどおり VR を使ってね。ダッシュボードは閉じて大丈夫」「3〜4 回、左右の耳のほうを 3 秒くらい見てね」「何回か、目を開けたまま下(手元)を見てね」「話したり笑ったりして大丈夫。まばたきも自然にね」と灰色で「始まりにビープが 1 回、終わると低いビープが 2 回鳴るよ」、[3 分の撮影をはじめる]。任意のふだん使いは左に[戻る]。押せるのは eyecam-rec が待機中(かエラー)・コマンドの返事待ちでない・まとめていない・その録画と 1 GiB が空いているとき(`contrib::startAllowed`)。押せないときはボタンの上に赤で「空きが足りないよ(約 2.8 GB 要る)」か灰色で「目のカメラがほかのことをしているあいだは待ってね」 + - **録画中**: eyecam-rec が探している・録っているあいだは目の撮影の画面(`drawRun`)のまま(その前後のつなぎ目は見出しと「送っています…」だけ)、見出しだけ「AI の学習に協力する・決まった動き 2」。ふだん使いはステップのチップを出さず、指示を「ふだんどおり使ってね」にする(あと何秒・バー・[中止]は同じ)。シートを閉じていても、詳細タブの録画の画面は同じ見出しになる + - **録れなかった**: ステップの名前、赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった)、途中までのフォルダがあれば灰色で「途中までの分は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度録る](かぶり直しはやり直さない) + - **録り終わり**: 緑のチェックと「全部録れたよ。ありがとう!」、「合計 10.5 GB」、灰色で「場所: ~/eyecam/contrib_日時/」、「1 つのファイルにまとめて、Google ドライブなどで開発者に送ってね。」。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.9 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(6.5 GB)」とその下にファイル名(まとめるボタンはもう押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。下の左に[この録画を消す]、右に[もう 1 回ふだん使いを録る(任意)](録ったら出さない) + - **消す確認**: 赤い枠のカードで「この録画を消す?」「~/eyecam/contrib_日時/ を中身ごと(10.5 GB)消します。元に戻せないよ。開発者に送ったあとで消してね。」、[やめる](アクセント)と[消す]。消している間は「消しています…」 +- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib_日時/` のうち `info.txt` が読めるいちばん新しいものを読む。[協力する]でそのフォルダと `info.txt` を作る。[撮影をはじめる]は `start widen_nolight` か `start free` を `ctl.sock` に送り、`info.txt` に `running` と送った時刻を書く(パネルが途中で起動し直しても、その時刻から追い続ける)。終わりは `status.json` だけで見る(`contrib::RunWatch`): そのプロトコルの `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだ録画は `rename` で協力のフォルダへ、止まった・失敗したものの途中までは `aborted/` へ移す(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない) +- **かぶり直しの見分け**(`contrib::WearWatch`。ダッシュボードを閉じていても `status.json` を読むたびに見る): 外した=eyecam-rec が待機中でライブ処理がオンのときに目の映像が 3 秒止まった(`locked` が false)、近接センサーが「かぶっている」を読んだあとで 3 秒「かぶっていない」を読んだ(顔によっては、かぶっていても閾値に届かないので、一度も「かぶっている」を読まなければ数えない)、またはパネルのループが 3 秒以上止まった(HMD を外すと Frame がスリープする。CLOCK_BOOTTIME で測る)。かぶった=そのあと目の映像が 2 秒戻った(ライブ処理がオフならセンサーが 2 秒「かぶっている」)。分かったら `info.txt` の `next_wear=detected`、[かぶり直した]なら `button`、済んだステップには `stepN_wear` として残す(1 回目は `first`) +- **フォルダ**: `~/eyecam/contrib_日時/` に `info.txt` と、済んだステップの `rec_日時/`(eyecam-rec が書いたまま)、`aborted/rec_日時/`(途中まで。まとめるファイルには入れない)、まとめたファイル `contrib_日時.tar.zst`(zstd が無ければ `.tar`)。`info.txt` は `key=value`: `format`・`app_version`・`consent`(時刻とタイムゾーン)・`stepN_protocol` / `_rec` / `_bytes` / `_wear` / `_done`(N は 1〜5)・`next_wear`・`running` / `running_since`・`aborted`・`packed` / `packed_bytes`。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) +- **まとめる**(`contrib::Worker`、別のスレッド、nice 10): `info.txt` と済んだ録画だけを ustar の tar にして(8 GiB 以上の大きさは GNU の base-256)、zstd があれば `zstd -3 -T2` に通す(Frame の目の映像は 0.62 ほどになる)。`.part` に書いて fsync してから名前を変え、失敗したら `.part` を消す。始める前に空きを確かめる(録画の 0.75 倍〔zstd なし: 1 倍〕と 1 GiB)。元の録画には触らない +- **消す**: 確認のあと、`~/eyecam` の直下の `contrib_日時` だけを(中のリンクはたどらずに)丸ごと消す。消したら同意の画面に戻る + ## ほかのアプリから動かす(--action) `frameeyeosc-panel --action NAME` で、常駐しているパネルにボタンを押してもらう(Frame のほかのアプリ、例えば aux のボタンの「押す = 合わせ直し、長押し = 目合わせ」のため)。パネルを起動はしない。`action_socket.{h,cpp}`。 @@ -389,6 +410,7 @@ systemctl --user daemon-reload | `src/config.*` | `config.json` の項目の表(型・既定値・範囲・刻み)、読み書き(一時ファイル → fsync → rename) | | `src/status.*` | `status.json` の読み込みと、本体が動いているかの判断 | | `src/eye_preview.*` | 目のプレビュー: どの値で両目を描くか、まぶた・瞳孔・視線の形、eyecam-rec の `preview` ファイルの読み方(mmap・seqlock・確かめ)、映像が出せるか、`preview on` / `off` の送り方、`--fake-preview` の作り物 | +| `src/contrib.*` | 「AI の学習に協力する」: ステップと大きさ、`info.txt` の読み書き、録画の終わりとかぶり直しの見分け(`status.json` から)、録画をフォルダに移す、tar と zstd でまとめる・消すスレッド、画面の決め方 | | `src/eyecam.*` | 目の撮影タブ(開発用): eyecam-rec の `status.json` の読み込み、タブと全面の光を出す条件と光のフェード、光の注意(始める前の確認と送るコマンド)、指示の文、`ctl.sock` への送信 | | `src/action_socket.*` | `--action`: 動作ごとの判断と終了コード、1 行のやりとり、コマンドの側(つないで 2 秒待つ)、パネルの側のソケット(ノンブロッキング、uid の確認) | | `src/autostart.*` | `systemctl --user` で自動起動を読む・切り替えるワーカースレッド | From 030f9c37773cb9c5fefcfc43ae6c656225746d01 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 12:38:13 +0900 Subject: [PATCH 35/65] =?UTF-8?q?eyecam:=20=E5=AD=A6=E7=BF=92=E7=94=A8?= =?UTF-8?q?=E3=81=AE=E6=89=8B=E9=A0=86=20protocol=5Fcontrib.txt=E3=80=81?= =?UTF-8?q?=E5=B7=A6=E5=8F=B3=E3=82=92=E8=A6=8B=E3=82=8B=E6=AE=B5=E3=81=AE?= =?UTF-8?q?=E9=9F=B3=E3=80=81status.json=20=E3=81=AB=20HMD=20=E3=82=92?= =?UTF-8?q?=E5=A4=96=E3=81=97=E3=81=9F=E5=9B=9E=E6=95=B0=E3=82=92=E8=B6=B3?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protocol_contrib.txt(93 秒・全体の光なし): 普通 10 秒のあと、閉じる・普通・細める・普通・見開くを 3 回、上・下・左・右を見る。 look_left は長 2 回(高→低)、look_right は長 2 回(低→高)の音にする(今までは細めと同じ音だった)。 status.json に started_unix と headset_off_count(起動してから、目の映像が止まるか近接センサーで 10 秒以上外して、またかぶった回数。 サスペンドも数える時計で測る。livesvc::OffCount)を足す。 Co-Authored-By: Claude Opus 5.5 --- tools/eyecam/README.md | 11 +++- tools/eyecam/protocol_contrib.txt | 30 ++++++++++ tools/eyecam/src/bin/eyecam-rec.rs | 27 +++++++-- tools/eyecam/src/cues.rs | 30 +++++++++- tools/eyecam/src/lib.rs | 7 +++ tools/eyecam/src/livesvc.rs | 93 ++++++++++++++++++++++++++++++ tools/eyecam/src/status.rs | 15 ++++- 7 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 tools/eyecam/protocol_contrib.txt diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index 652c4b5..da8bc34 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -160,7 +160,7 @@ uid 1000(steamos)前提で `/run/user/1000` は固定(`XDG_RUNTIME_DIR` | `fps_l`, `fps_r` | number | 録画中の左右のフレームレート(直近 1 秒)。録画中以外と、探し直し中は 0 | | `step_index` | int | いまのプロトコルの段の番号。**0 から**数えて、段が変わるたびに必ず変わる(パネルは「段 `step_index+1` / `step_count`」と出せる)。全段が終わると `step_count`(このとき `step_label` は `end`)。録画中以外は -1 | | `step_count` | int | プロトコルの段の数(`searching` / `recording` / `calibrating` のとき)。それ以外は 0 | -| `step_label` | string | `lead_in` / `normal` / `widen` / `close` / `squint` / `look_up` / `look_down` / `bright` / `dark` / `end`(自作プロトコルならそのラベル)。録画中以外は `""` | +| `step_label` | string | `lead_in` / `normal` / `widen` / `close` / `squint` / `look_up` / `look_down` / `look_left` / `look_right` / `bright` / `dark` / `end`(自作プロトコルならそのラベル)。録画中以外は `""` | | `step_remaining_s` | number | いまの段の残り秒数。`lead_in`(最初の 3 秒)の間はこれでカウントダウンを出せる | | `elapsed_s`, `total_s` | number | 録画開始からの秒数 / 録画の長さ(プロトコルの長さ + 1.5 秒)。`total_s` は `searching` のときから入る | | `session_dir` | string | 録画中(と、そのあとの `idle`)のセッションディレクトリ。無ければ `""` | @@ -191,6 +191,8 @@ uid 1000(steamos)前提で `/run/user/1000` は固定(`XDG_RUNTIME_DIR` | `last_error`, `last_error_en`, `last_error_unix` | string / string / number | 直近に `state` が `error` になったときの `message`(とその英語)と時刻(UNIX 秒)。`state` が戻っても残る。まだ無ければ `""` と 0 | | `lid_veto` | object | まぶたの拒否権(下の「まぶたの拒否権」)。`on`(settings.json の `lid_veto`。既定 false)、`runs_per_s`(CNN を回した回数、両目で直近 1 秒の毎秒)、`fired`(拒否権を出した回数)、`ms_mean` / `ms_max`(CNN 1 回の時間の平均と最大、ms)。`fired` と時間はライブ処理が始まってから数える。このフィールドがない古い eyecam-rec もある | | `driftlog` | object | ドリフトの記録(下の「ドリフトの記録」)。`on`(記録中。`driftlog on` から `off` まで)、`path`(書いているファイル。`off` のあとは最後のファイル。まだ一度も無ければ `""`)、`rows`(書いた行数)、`bytes`(ファイルの大きさ)、`full`(50 MB に達したのでそれ以上書いていない。`on` のまま)、`error`(書けなくなった理由。ふつうは `""`)。このフィールドがない古い eyecam-rec もある | +| `started_unix` | number | この eyecam-rec が起動した時刻(UNIX 秒)。`headset_off_count` は起動ごとに 0 から数えるので、比べるのは同じ `started_unix` どうしだけ | +| `headset_off_count` | int | 起動してから、HMD を外して(10 秒以上。`livesvc::NEW_WEAR_OFF_S`)またかぶった回数。外したとみなすのは、待機中にライブ処理がオンで目の映像が止まった(`locked` が false。映像が戻ったときに数える)か、近接センサーが一度「かぶっている」を読んだあとに「かぶっていない」を読み続けた(また「かぶっている」を読んだときに数える)とき。どちらもサスペンドの時間も数える時計(CLOCK_BOOTTIME)で測るので、外して Frame がスリープしても数える。1 回の取り外しは、先に戻った方で 1 回だけ数える。録画・校正のあいだは数えない(`livesvc::OffCount`)。パネルの「細めの学習用に撮る」が、2 本の録画のあいだに外したかを見るのに使う。このフィールドがない古い eyecam-rec もある | | `pid` | int | デーモンのプロセス ID | | `updated_unix` | number | 書いた時刻(UNIX 秒) | @@ -1004,6 +1006,8 @@ journalctl --user -u eyecam -f # ログ | 長 1 回(中くらいの高さ) | squint(細める) | | 長 1 回(高い音) | look_up(上を見る) | | 長 1 回(低い音) | look_down(下を見る) | +| 長 2 回(高い音→低い音) | look_left(自分から見て左を見る) | +| 長 2 回(低い音→高い音) | look_right(自分から見て右を見る) | | 短 2 回(とても高い音) | bright(明るいものを見る) | | 短 2 回(低い音) | dark(暗いものを見る) | | 長 2 回(低い音) | 終わり | @@ -1011,6 +1015,11 @@ journalctl --user -u eyecam -f # ログ `protocol_widen.txt`(83 秒): lead_in 3 秒 → (close 2 秒, normal 5 秒, widen 5 秒) × 3 → squint 5 秒 → look_up 5 秒 → look_down 5 秒 → normal 5 秒 → 瞳孔チェック: bright 8 秒 → dark 8 秒 → bright 8 秒。 +`protocol_contrib.txt`(93 秒。全体の光なし。パネルの「細めの学習用に撮る」が `start contrib` で撮る、半目の見分けの学習用): lead_in 3 秒 → +normal 10 秒(かぶったばかりでも明るさの合わせと古典の処理が落ち着くまで)→ (close 3 秒〔学習に使うのは閉じて 1.0 秒たってから〕, +normal 4 秒, squint 4 秒〔まぶしいときのように細める。閉じない〕, normal 4 秒, widen 4 秒) × 3 → look_up 4 秒 → look_down 4 秒 → +look_left 4 秒 → normal 3 秒 → look_right 4 秒 → normal 4 秒。左右は本人から見た向き。 + 瞳孔チェックのあいだは目を開けたまま、bright の合図で明るい画面(白っぽい SteamVR ホームやダッシュボードなど)を、 dark の合図で暗い画面(暗いシーン、ダッシュボードを閉じた真っ暗な状態など)を見る。目は閉じないでね(瞳孔の大きさの変化を見るため)。 自分で作るときは 1 行に `秒数 ラベル`(`#` 以降はコメント)。上の表にないラベルは長 1 回(中)。 diff --git a/tools/eyecam/protocol_contrib.txt b/tools/eyecam/protocol_contrib.txt new file mode 100644 index 0000000..05a0782 --- /dev/null +++ b/tools/eyecam/protocol_contrib.txt @@ -0,0 +1,30 @@ +# eyecam cue protocol for the panel's "Record for squint training" (training data for the half-shut check): no +# full-view light. `seconds label` per line. Beeps: lead_in = 1 short (high), normal = 1 short, close = 3 short, +# squint = 1 long (mid), widen = 2 short, look_up = 1 long (high), look_down = 1 long (low), look_left = 2 long +# (high, then low), look_right = 2 long (low, then high); end = 2 long (low). Left and right as the wearer sees them. +3 lead_in +# Relaxed open while the brightness levels and the classic engine settle (the headset was just put on, often) +10 normal +# Three times: closed (3 s: training uses a closed eye from 1.0 s after it shuts), normal, squint (as in bright +# light, not closing), normal, widen +3 close +4 normal +4 squint +4 normal +4 widen +3 close +4 normal +4 squint +4 normal +4 widen +3 close +4 normal +4 squint +4 normal +4 widen +4 look_up +4 look_down +4 look_left +3 normal +4 look_right +4 normal diff --git a/tools/eyecam/src/bin/eyecam-rec.rs b/tools/eyecam/src/bin/eyecam-rec.rs index 33d0157..3a80604 100644 --- a/tools/eyecam/src/bin/eyecam-rec.rs +++ b/tools/eyecam/src/bin/eyecam-rec.rs @@ -573,7 +573,7 @@ impl Daemon { }); let listener = bind_private(&dir.join(status::CTL_FILE), "another eyecam-rec --serve is already running")?; listener.0.set_nonblocking(true)?; - let status = Arc::new(Mutex::new(Status::default())); + let status = Arc::new(Mutex::new(Status { started_unix: unix_now(), ..Status::default() })); let shutdown = Arc::new(AtomicBool::new(false)); let (tx, requests) = mpsc::channel(); file.write(&status); @@ -792,6 +792,8 @@ fn serve(args: &Args) -> Result<()> { }; eprintln!("live output: {}", args.run_dir.join("live").display()); let mut prox = Proximity::find(); + // (kept across the buffers being fetched again: it counts for the whole run) + let mut off = livesvc::OffCount::default(); let mut why: Option<&str> = None; while !STOP.load(Ordering::SeqCst) { let message = why.map_or(INSTALL_TOOL.to_string(), |w| format!("{w}。{INSTALL_TOOL}")); @@ -829,7 +831,7 @@ fn serve(args: &Args) -> Result<()> { } }; live.send(Msg::Buffers(true)); - let outcome = idle(&mut buffers, args, &mut prox, &daemon, &mut live); + let outcome = idle(&mut buffers, args, &mut prox, &daemon, &mut live, &mut off); live.send(Msg::Buffers(false)); match outcome { Some(abort) => { @@ -981,8 +983,16 @@ impl Pump { } /// Wait for `start` / `calib` while holding the buffers, keeping the ring locked and feeding the live engine while -/// live processing is on. None on shutdown, or why the buffers had to be given up. -fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &mut Live) -> Option { +/// live processing is on, and counting the times the headset is taken off (`off`, status.json headset_off_count). +/// None on shutdown, or why the buffers had to be given up. +fn idle( + b: &mut Buffers, + args: &Args, + prox: &mut Proximity, + d: &Daemon, + live: &mut Live, + off: &mut livesvc::OffCount, +) -> Option { let mut cached: Option = None; let mut pump: Option = None; let mut deferred: Vec = Vec::new(); @@ -1123,6 +1133,8 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m eprintln!("eye frames stopped (idle); searching again"); pump = None; came_back = true; + // (they stopped LOST_AFTER ago) + off.frames_lost(eyecam::now_boot() - LOST_AFTER); live.send(Msg::Stale); next_search = now + 1.0; if state.0 == "idle" { @@ -1133,6 +1145,7 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m } else if now >= next_search { if let Some(r) = cached.take().filter(|r| ring_is_live(&b.arenas[r.arena], r)) { pump = Some(Pump::new(&b.arenas[r.arena], r)); + off.frames_back(eyecam::now_boot()); if std::mem::take(&mut came_back) { live.send(Msg::NewWear("the eye frames came back")); } @@ -1140,6 +1153,7 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m match lock_ring(b, args, prox, Some(d), Ctx::Idle, &mut || true, &mut deferred) { Ok(r) => { pump = Some(Pump::new(&b.arenas[r.arena], r)); + off.frames_back(eyecam::now_boot()); if std::mem::take(&mut came_back) { live.send(Msg::NewWear("the eye frames came back")); } @@ -1164,6 +1178,8 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m if let Err(a) = b.watch(reading.map(|v| v > args.prox_min)) { return Some(a); } + off.prox(reading.map(|v| v > args.prox_min), eyecam::now_boot()); + let off_count = off.count(); match reading { Some(v) if v > args.prox_min => { if prox_off_since.take().is_some_and(|off| now - off >= livesvc::NEW_WEAR_OFF_S) && live.on { @@ -1184,6 +1200,7 @@ fn idle(b: &mut Buffers, args: &Args, prox: &mut Proximity, d: &Daemon, live: &m s.message = if waiting && searching { status::search_message(s.search).into() } else { state.1.clone() }; s.locked = locked; s.prox = reading.unwrap_or(-1.0); + s.headset_off_count = off_count; if !searching { s.search = ""; } @@ -3092,7 +3109,7 @@ mod tests { let mut b = still_buffers(sizes); let mut prox = Proximity::find(); live.send(Msg::Buffers(true)); - let r = idle(&mut b, &args, &mut prox, &daemon, &mut live); + let r = idle(&mut b, &args, &mut prox, &daemon, &mut live, &mut livesvc::OffCount::default()); live.stop(); daemon.finish(); r diff --git a/tools/eyecam/src/cues.rs b/tools/eyecam/src/cues.rs index a5deee0..e0833bd 100644 --- a/tools/eyecam/src/cues.rs +++ b/tools/eyecam/src/cues.rs @@ -2,8 +2,9 @@ //! //! Protocol file: one step per line, `seconds label` (e.g. `5 widen`); `#` starts a comment. //! Beeps: lead_in = 1 short (high), normal = 1 short, widen = 2 short, close = 3 short, squint = 1 long (mid), -//! look_up = 1 long (high), look_down = 1 long (low), bright = 2 short (high), dark = 2 short (low), -//! anything else = 1 long (mid); the end of the protocol = 2 long (low). +//! look_up = 1 long (high), look_down = 1 long (low), look_left = 2 long (high, then low), look_right = 2 long (low, +//! then high), bright = 2 short (high), dark = 2 short (low), anything else = 1 long (mid); the end of the protocol = +//! 2 long (low). use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -52,6 +53,9 @@ pub fn pattern(label: &str) -> (&'static str, &'static [(f32, u32)]) { "close" => ("3short", &[(1000.0, SHORT), (1000.0, SHORT), (1000.0, SHORT)]), "look_up" => ("long_high", &[(1400.0, LONG)]), "look_down" => ("long_low", &[(550.0, LONG)]), + // (left and right as the wearer sees them: the pitch falls toward the left, rises toward the right) + "look_left" => ("2long_falling", &[(1400.0, LONG), (550.0, LONG)]), + "look_right" => ("2long_rising", &[(550.0, LONG), (1400.0, LONG)]), "bright" => ("2short_high", &[(1800.0, SHORT), (1800.0, SHORT)]), "dark" => ("2short_low", &[(450.0, SHORT), (450.0, SHORT)]), "end" => ("2long_low", &[(550.0, LONG), (550.0, LONG)]), @@ -175,6 +179,28 @@ mod tests { assert_eq!(parse("2 look up # x").unwrap()[0].label, "look_up"); } + #[test] + fn the_contribution_protocol_has_a_sound_for_each_step() { + let steps = parse(include_str!("../protocol_contrib.txt")).unwrap(); + assert_eq!(steps.iter().map(|s| s.seconds).sum::(), 93.0); + assert_eq!(steps.iter().filter(|s| s.label == "close").count(), 3); + assert!(steps.iter().all(|s| s.label != "bright" && s.label != "dark")); + // Every step its own sound (none falls back to "anything else", which squint has), each distinct + let names: Vec<&str> = steps.iter().map(|s| pattern(&s.label).0).collect(); + for (step, name) in steps.iter().zip(&names) { + assert!(step.label == "squint" || *name != "long_mid", "{}", step.label); + } + let mut labels: Vec<&str> = steps.iter().map(|s| s.label.as_str()).collect(); + labels.sort(); + labels.dedup(); + let mut sounds: Vec<&str> = labels.iter().map(|l| pattern(l).0).collect(); + sounds.sort(); + sounds.dedup(); + assert_eq!(sounds.len(), labels.len()); + assert!(!labels.iter().any(|l| pattern(l).0 == pattern("end").0)); + assert_ne!(pattern("look_left").1, pattern("look_right").1); + } + #[test] fn wav_has_the_right_length() { let w = wav(pattern("widen").1, 0.3); diff --git a/tools/eyecam/src/lib.rs b/tools/eyecam/src/lib.rs index 14a574f..50e70e0 100644 --- a/tools/eyecam/src/lib.rs +++ b/tools/eyecam/src/lib.rs @@ -26,3 +26,10 @@ pub fn now_raw() -> f64 { unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC_RAW, &mut ts) }; ts.tv_sec as f64 + ts.tv_nsec as f64 * 1e-9 } + +/// CLOCK_BOOTTIME in seconds: like the monotonic clocks, but counting the time the Frame was suspended. +pub fn now_boot() -> f64 { + let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + unsafe { libc::clock_gettime(libc::CLOCK_BOOTTIME, &mut ts) }; + ts.tv_sec as f64 + ts.tv_nsec as f64 * 1e-9 +} diff --git a/tools/eyecam/src/livesvc.rs b/tools/eyecam/src/livesvc.rs index d3ae5c8..2d4960d 100644 --- a/tools/eyecam/src/livesvc.rs +++ b/tools/eyecam/src/livesvc.rs @@ -107,6 +107,63 @@ pub enum CollectKind { Pupil, } +/// How many times the headset looked taken off and was then worn again since eyecam-rec started (status.json +/// `headset_off_count`; the panel's "Help train the AI" tells recordings made in one wear from ones with the headset +/// taken off between). Off for NEW_WEAR_OFF_S or more, as the new-wear hints see it: the eye frames stopped (idle with +/// live processing on, which keeps looking at them), or the proximity sensor read not worn after it had read worn (a +/// face it never reaches the threshold on never counts by it). Measured on a clock that runs through suspend +/// (taking the headset off lets the Frame sleep). One taking-off counts once, when the headset is worn again: the +/// frames back, or the sensor reading worn, whichever comes first. +#[derive(Debug, Default)] +pub struct OffCount { + count: u64, + frames_lost: Option, + prox_off: Option, + prox_worn_seen: bool, +} + +impl OffCount { + /// The eye frames stopped at `now` (seconds on the boot clock; a later call before frames_back keeps the first). + pub fn frames_lost(&mut self, now: f64) { + self.frames_lost.get_or_insert(now); + } + + /// The eye frames came back. + pub fn frames_back(&mut self, now: f64) { + if self.frames_lost.take().is_some_and(|t| now - t >= NEW_WEAR_OFF_S) { + self.worn_again(); + } + } + + /// A proximity reading: worn (above --prox-min), not worn, or None when it can't be read. + pub fn prox(&mut self, worn: Option, now: f64) { + match worn { + Some(true) => { + self.prox_worn_seen = true; + if self.prox_off.take().is_some_and(|t| now - t >= NEW_WEAR_OFF_S) { + self.worn_again(); + } + } + Some(false) if self.prox_worn_seen => { + self.prox_off.get_or_insert(now); + } + _ => {} + } + } + + /// The times it was taken off and worn again. + pub fn count(&self) -> u64 { + self.count + } + + /// One taking-off ended: counted, and the other sign of it forgotten so it doesn't count again. + fn worn_again(&mut self) { + self.count += 1; + self.frames_lost = None; + self.prox_off = None; + } +} + /// The eyes a user calibration went through with the standard look-down correction on (`default`) or with the /// previous one kept (`previous`): UserOutcome's, for calib_result.json. #[derive(Clone, Copy, Debug, Default)] @@ -1159,6 +1216,42 @@ mod tests { use super::*; use crate::replay::Session; + #[test] + fn off_count_counts_each_taking_off_once() { + let mut o = OffCount::default(); + // The frames stop for 30 s (the sensor reading off meanwhile too): one, when either says worn again + o.prox(Some(true), 0.0); + o.frames_lost(1.0); + o.prox(Some(false), 2.0); + o.prox(Some(false), 20.0); + o.frames_back(31.0); + assert_eq!(o.count(), 1); + o.prox(Some(true), 32.0); + assert_eq!(o.count(), 1); + // A short gap (the tracker pausing, a glance under the headset) is no taking-off + o.frames_lost(40.0); + o.frames_back(45.0); + o.prox(Some(false), 50.0); + o.prox(Some(true), 55.0); + assert_eq!(o.count(), 1); + // The sensor alone (live processing off: no frames to go by) + o.prox(Some(false), 60.0); + o.prox(Some(false), 75.0); + o.prox(Some(true), 76.0); + assert_eq!(o.count(), 2); + // Asleep in between: the boot clock jumped, the frames come back after it + o.frames_lost(100.0); + o.frames_back(400.0); + assert_eq!(o.count(), 3); + // A sensor that can't be read, or one that never read worn, doesn't count + let mut low = OffCount::default(); + low.prox(Some(false), 0.0); + low.prox(Some(false), 60.0); + low.prox(None, 61.0); + low.prox(Some(true), 62.0); + assert_eq!(low.count(), 0); + } + #[test] fn pupil_share_covers_the_last_two_seconds() { let mut p = PupilSeen::default(); diff --git a/tools/eyecam/src/status.rs b/tools/eyecam/src/status.rs index 6b86d94..1b42195 100644 --- a/tools/eyecam/src/status.rs +++ b/tools/eyecam/src/status.rs @@ -230,6 +230,10 @@ pub struct Status { pub driftlog: crate::driftlog::DriftStatus, /// The eyelid veto (settings.json `lid_veto`) and what it has done. pub lid_veto: LidVeto, + /// When this eyecam-rec started (Unix seconds): a `headset_off_count` only compares within one run. + pub started_unix: f64, + /// The times the headset looked taken off and was worn again since it started (livesvc::OffCount). + pub headset_off_count: u64, } /// status.json `lid_veto`: whether the eyelid veto is on, its CNN runs a second (both eyes, over the last second), @@ -301,6 +305,8 @@ impl Default for Status { last_error_unix: 0.0, driftlog: Default::default(), lid_veto: LidVeto::default(), + started_unix: 0.0, + headset_off_count: 0, } } } @@ -333,7 +339,7 @@ impl Status { "{{\"version\":1,\"state\":{},\"message\":{},\"message_en\":{},\"has_buffers\":{},\"auto_grab\":{},\"grab_outdated\":{},\"locked\":{},\"fps_l\":{},\"fps_r\":{},\ \"step_index\":{},\"step_count\":{},\"step_label\":{},\"step_remaining_s\":{},\"elapsed_s\":{},\"total_s\":{},\ \"session_dir\":{},\"protocol\":{},\"prox\":{},\"search\":{},\"last_session_aborted\":{},\"calib_state\":{},\ -\"recalib_suggested\":{},\"baseline\":{},\"warmup_remaining_s\":{},\"calib_saved\":{},\"widen_sensitivity\":{},\"dev\":{},\"setup_done\":{},\"last_calib_widen\":{},\"calib_failed_eye\":{},\"user_look_down_default\":[{},{}],\"user_look_down_previous\":[{},{}],\"live\":{},\"live_ms\":{},\"preview\":{},\"pupil_l\":{},\"pupil_r\":{},\"cam_fps\":[{},{}],\"prox_min\":{},\"search_detail\":{},\"last_calib\":{},\"last_error\":{},\"last_error_en\":{},\"last_error_unix\":{},\"lid_veto\":{},\"driftlog\":{},\"pid\":{pid},\"updated_unix\":{}}}", +\"recalib_suggested\":{},\"baseline\":{},\"warmup_remaining_s\":{},\"calib_saved\":{},\"widen_sensitivity\":{},\"dev\":{},\"setup_done\":{},\"last_calib_widen\":{},\"calib_failed_eye\":{},\"user_look_down_default\":[{},{}],\"user_look_down_previous\":[{},{}],\"live\":{},\"live_ms\":{},\"preview\":{},\"pupil_l\":{},\"pupil_r\":{},\"cam_fps\":[{},{}],\"prox_min\":{},\"search_detail\":{},\"last_calib\":{},\"last_error\":{},\"last_error_en\":{},\"last_error_unix\":{},\"lid_veto\":{},\"driftlog\":{},\"started_unix\":{},\"headset_off_count\":{},\"pid\":{pid},\"updated_unix\":{}}}", json_str(self.state), json_str(&self.message), json_str(&crate::message_en::message_en(&self.message)), @@ -383,6 +389,8 @@ impl Status { num(self.last_error_unix), self.lid_veto.to_json(), self.driftlog.to_json(), + num(self.started_unix), + self.headset_off_count, num(updated_unix), ) } @@ -574,6 +582,9 @@ mod tests { let status = Status { message: "録画中".into(), ..Status::default() }; assert!(status.to_json(1.5, 7).contains("\"message\":\"録画中\",\"message_en\":\"Recording\",")); assert!(json.ends_with("\"updated_unix\":1.500}")); + let off = Status { started_unix: 1_790_000_000.25, headset_off_count: 3, ..Status::default() }.to_json(1.5, 7); + assert!(off.contains(",\"started_unix\":1790000000.250,\"headset_off_count\":3,\"pid\":7,"), "{off}"); + assert!(json.contains(",\"started_unix\":0.000,\"headset_off_count\":0,"), "{json}"); assert!(json.contains("\"widen_sensitivity\":0.500,\"dev\":false,\"setup_done\":false,")); assert!(Status { dev: true, ..Status::default() }.to_json(1.5, 7).contains(",\"dev\":true,")); assert!(json.contains("\"last_calib_widen\":\"\",\"calib_failed_eye\":\"\",")); @@ -595,7 +606,7 @@ mod tests { "{json}" ); assert!( - json.contains(",\"driftlog\":{\"on\":false,\"path\":\"\",\"rows\":0,\"bytes\":0,\"full\":false,\"error\":\"\"},\"pid\":7,"), + json.contains(",\"driftlog\":{\"on\":false,\"path\":\"\",\"rows\":0,\"bytes\":0,\"full\":false,\"error\":\"\"},\"started_unix\":"), "{json}" ); assert!(json.contains(",\"lid_veto\":{\"on\":false,\"runs_per_s\":0.000,\"fired\":0,\"ms_mean\":0.000,\"ms_max\":0.000},\"driftlog\":"), "{json}"); From fa8519d7eb22557cb1f4e126d8a257755975bb00 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 12:38:13 +0900 Subject: [PATCH 36/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E3=80=8CA?= =?UTF-8?q?I=20=E3=81=AE=E5=AD=A6=E7=BF=92=E3=81=AB=E5=8D=94=E5=8A=9B?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=80=8D=E3=82=92=E3=80=8C=E7=B4=B0=E3=82=81?= =?UTF-8?q?=E3=81=AE=E5=AD=A6=E7=BF=92=E7=94=A8=E3=81=AB=E6=92=AE=E3=82=8B?= =?UTF-8?q?=E3=80=8D=E3=81=AB=E4=BD=9C=E3=82=8A=E7=9B=B4=E3=81=99=EF=BC=88?= =?UTF-8?q?=E3=83=9C=E3=82=BF=E3=83=B3=201=20=E3=81=A4=E3=81=A7=201=20?= =?UTF-8?q?=E6=9C=AC=E6=92=AE=E3=82=8A=E3=80=81~/eyecam/contrib/=20?= =?UTF-8?q?=E3=81=AB=E3=81=9F=E3=82=81=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit かぶり直しの検出・ステップ・続きからを外し、初めてだけ同意(版つき)と眼鏡・インサートの質問、そのあと protocol_contrib.txt を 1 回撮る形にする。 終わった録画は 4 つのファイル(frames/valve/cues/meta)がそろっていれば ~/eyecam/contrib/ に移し、info.txt に 1 本ずつ節を書き足す (版・設定・眼鏡の答え・前の録画からの分・そのあいだに外したか・校正結果の写し)。足りない・止まったものは aborted/ へ。 終わりの画面と同意の画面に送り方の注意(非公開で)を出し、まとめるのは zstd -6(Frame で測って 0.58 倍)、まとめ直したら前のファイルを消す。 左右を見る段の文とチップを足す。contrib-test と panel-test を作り直す。 Co-Authored-By: Claude Opus 5.5 --- panel/README.md | 44 +- panel/src/contrib.cpp | 798 ++++++++++++++-------------- panel/src/contrib.h | 584 ++++++++++---------- panel/src/eyecam.cpp | 11 + panel/src/eyecam.h | 6 +- panel/src/i18n.cpp | 198 ++++--- panel/src/i18n.h | 76 ++- panel/src/main.cpp | 198 +++---- panel/src/panel.cpp | 370 ++++++------- panel/src/panel.h | 64 +-- panel/tests/contrib_test.cpp | 996 +++++++++++++++++------------------ panel/tests/panel_test.cpp | 130 ++--- 12 files changed, 1680 insertions(+), 1795 deletions(-) diff --git a/panel/README.md b/panel/README.md index b94c5f2..cf70906 100644 --- a/panel/README.md +++ b/panel/README.md @@ -31,7 +31,7 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ | 目を合わせる | [目を合わせる](ヒント「正面・上の幅・まぶた・約 20 秒」。合わせたあとは[もう一度合わせる])と小さい[真ん中だけ合わせ直す]、「被ったとき」の行(何もしない / 真ん中だけ、`auto_recenter`。既定は真ん中だけ。0.7.6 までの `"tilt"` は `"center"` として読む)、今どうなっているか(始め方・結果[元に戻す]・止めた / 失敗の理由。被ったときの合わせ直しをダッシュボードが止めて、もう一度やるときは「ダッシュボードを閉じたら、もう一度合わせ直します」も)。目のカメラの校正中(`eyecam::calibrationUnderWay`: eyecam-rec が `calibrating`、目の映像を待っている間も。パネルが送って返事を待っている間も)は[目を合わせる]と[真ん中だけ合わせ直す]がグレーで、箱の見出しが「目のカメラの校正が終わってから使えます」。目を合わせている間はタブ全体が「点を見てください」と何をするか(動かない文字)と大きな[やめる]だけになり、左の列とタブの並びは暗くなる(押せるのはタブと[やめる]だけ。ほかのタブを押すと止まる)、[♪ 音を鳴らす: オン / オフ]、「細かく直す」(開閉。[視線 / まぶた]で切り替え。視線: 正面の位置・動く幅(左右・上・下)、真下で左右を止める角度。まぶた: 目ごとの読んだ値 閉じ / 上 / 正面 / 下 の − / +。閉じているあいだは、その下に「合わせ方」の箱: 被るたびは真ん中の点を 1 つ見るだけ(自動)、[目を合わせる]は最初と気になったときだけ、傾きは使わなくなったこと) | | まぶた | 見た目の調整を全部ここに(案E)。いちばん上の切り替えで 4 つの小タブ(詳細タブと同じ形。初めては「見た目」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `lids_page` で覚えている)。「見た目」: 「いまのまぶた」の帯(目のカメラ(左・右)/ 目のカメラ(左)+ Valve(右)/ 目のカメラ(待機中)/ Valve の値。準備が済んでいれば右に「オン・オフは「基本」タブで」)、「見開き」の行(する / しない で `widen` を `SetBool`。カメラを使っていてもいなくてもいつも出す。ヒント「閉じ・細めはそのまま」。「しない」のあいだは下の「見開きの出やすさ」を灰色にして押せなくし、その下の 1 行を「「見開き」が「しない」なので、いまは使わないよ」に。目のカメラタブのいちばん下の文も「カメラで両目を見て、まぶたを送ってるよ(見開きはしない)」に、診断の「まぶた」の行には「· 見開きしない」を足す。`--fake-no-widen` で描ける)、目のカメラを使っているときだけ「見開きの出やすさ」のスライダー 1 本(緑の「カメラ」バッジ。鈍い ↔ 敏感。両目がカメラなら eyecam-rec の `widen_sensitivity` を `set widen_sensitivity` で送る。片目だけカメラなら同じく感度を送り、Valve の目には近い `lid_widen` の段階も一緒に書く。Valve の値なら `lid_widen` の 4 段階(しない / 控えめ / ふつう / 出やすい)で、押した所に近い止まり位置へ。目を合わせていない目があると下に「目を合わせていない目は、「細かく直す」の目盛りで決まるよ」。いまカメラの目が無く、ふつうに開いた目が 1.0 と読まれる SteamOS(`openness_saturated`)では、スライダーの代わりに 1 行: ヒント「この SteamOS では効かないよ」、「Valve の値だけだと見開きが出ないよ」と[目のカメラへ →](目のカメラタブがあるときだけ))、目のカメラを使っていて eyecam-rec の status.json に `lid_veto.on` があるときだけ「半目の読み直し(AI)」(する / しない。緑の「カメラ」バッジ。eyecam-rec に `set lid_veto on|off` を送り、選ばれているほうは status.json の `lid_veto.on` のまま〔panel.json には持たない〕。下に 3 行までの説明。`--fake-eyecam …:veto=on|off` で描ける)、まぶたのなめらかさ(弱 / 中 / 強。中 = 本体の既定値、どれにも合わなければカスタム)、左右をそろえる強さ(`lid_sync` のスライダー、0.05 刻み)。目のカメラを使っていないときは、いちばん下に点線の 1 行「目のカメラを使うと「見開きの出やすさ」が増えるよ」(押すと目のカメラタブ)。なので、カメラが無いと `lid_widen` はパネルから変えられない。「まばたき・細め」: まばたきを届ける(保持 ms の − / +)、まばたきを両目でそろえる(`blink_sync_below` の − / +、0 でオフ)、細めたときのまぶたの下限(`camera_lid_floor` の − / +、0.05 刻み、0 でオフ。目のカメラを使っているときだけ出す。緑の「カメラ」バッジ。カメラの目がまだないときは「目のカメラが目を見てるときに効くよ」と添える。使っていないときは点線の 1 行「目のカメラを使うと「細めたときのまぶたの下限」も使えるよ」)。それぞれ右に説明。「細かく直す」: いちばん上に目盛りの決まり方の 1 行、自動キャリブレーション(目を合わせていない目で、カメラでないとき)、左右の倍率、生の値の棒と 4 つの目盛り(③④は目を合わせた目とカメラのときは灰色)、ほぼ開いてたら全開にする(⑤。生の値の棒にも ⑤ の青い破線を出す。目を合わせた目は倍率をかけた位置、カメラが読んでいる目は薄く、0.75 のときは出さない。行の数字と − / + の縁も同じ青。`lid_open_snap` の − / +、送るまぶたの値で 0〜0.75 の 0.01 刻み、0.75 でオフ。いつも出す。右に説明。開いた値に対する割合だったころの 0.75 より大きい値は、起動時に一度だけ置き換える(`migrateLidOpenSnap`))、なめらかさの 2 つの値(止まっている時・速い動き。ページに収まるよう、それぞれの左に名前を置いた 1 行)。目のカメラタブの[まぶたへ →]は「見た目」を開く。「眉」: 「見開きで眉も上げる」(する / しない で `brow_from_widen` を `SetBool`。ヒント「カメラは眉を見ていないよ」。カメラを使っていてもいなくても、「見開き」が「しない」でもいつも出す。下に 3 行までの説明〔`\n` の所で改行〕)と、「する」のあいだだけ「眉の上がり方」(`brow_strength` のスライダー、弱い ↔ 強い、0.05 刻み。ヒント「アバターに合わせて」)。`--fake-brows` で「する」を描ける。`--lids-page look|blinks|fine|brows` で小タブを選んで描く(`--lid-marks` は fine と同じ) | | 目のカメラ | eyecam-rec が動いているあいだだけ出る(「詳細」の前。出る条件は下の「目の撮影」と同じ)。準備を始める前(準備が済んだことがなく、まだ ① か ② の段で、このパネルを起動してから[はじめる]を押していないとき。`eyecam::introShown`)は、「目のカメラでできること」(見開き・瞳孔の大きさ・Valve がまぶたを低く読んだときにカメラで確かめて直す)、1 回だけの準備と目の映像は Frame の外に出さないことの箱、[はじめる(約 2 分)]、「使わなくても、目の送信はふつうに動きます」。押したあと(と準備が進んでいるとき)は準備のチェックリスト(いまの段階は、満たしていない最初のもの。毎回 status.json とパスワードの確認だけで決める): ① パスワードを決める(SteamOS にパスワードが無いと分かっているときだけ。`steamos-passwd --has-password` を裏で数秒おきに確かめる。読むだけ。[Konsole で passwd を開く])② 道具を入れる([Konsole で開く]で `sudo $HOME/.local/lib/eyecam/install_grab.sh` が入力済みの Konsole を開くだけ。Enter とパスワードは本人で、パネルは sudo も passwd も実行しない。`auto_grab` が ok / trying / waiting_tracker / failed: … か、バッファを持っていれば入っている扱い。`auto_grab` が `too_old`(eyecam の安全のための下限より古く、起動されない)なら入っていない扱い。`grab_outdated` が true(更新で新しい道具が届いた)か too_old なら、見出しを「道具を入れ直す」、その下に「道具が新しくなったよ、入れ直してね」)③ 目の動きを覚える(カメラの映像は「届いてるよ」、`cam_fps` が毎秒 60 枚より少なければアクセント色で「届いてるよ(毎秒 15 枚・少なめ)」。目ごとに「見えてるよ」(status.json の `pupil_l` / `pupil_r` で瞳が 50 % 以上)・「映像はあるけど瞳が見つからない」(`locked` だけど 20 % 未満、赤)・「瞳がときどきしか見えない」(そのあいだ)・「見えてないよ」(`locked` が false)。`pupil_l` / `pupil_r` の無い古い eyecam-rec では今までどおり `locked` だけで決める。目の映像が届いているとき(`locked`)は[覚えはじめる]で `calib wear` を押せて、瞳が見つからない目があるときはその下に赤字で「HMD をかぶり直して」と出す。18 秒の間、ステップのチップ・残り秒・[やめる]をカードの中に出す。失敗したら eyecam-rec のメッセージ(数も入っている)の上に、メッセージが名前を出した目(「うまくいかなかった目」)を出す。見開きだけ取れなかったら[このまま進む][もう一度(18秒)]。片目だけ失敗して通ったとき(`calib_failed_eye` が L / R)は「左目は前の値を使うよ」(初めてなら「仮の値」)と目ごとの結果、メッセージ、[使いはじめる][もう一度(18秒)])④ 完了(「使いはじめる」)。セットアップ済みでも道具が無くなれば ② に戻り、直れば校正し直さずにふだんの画面へ。道具が古いだけなら ② には戻さず、ふだんの画面のいちばん上にお知らせのカードと[Konsole で開く](`grab_outdated`: 「道具が新しくなったよ、入れ直してね(今の道具のままでも動くよ)」、`too_old`: 赤いカードで「安全のため、道具を入れ直してね。入れ直すまでカメラは止まってて、まぶたは Valve の値だけで送ってるよ」)、左の列にも同じお知らせ(押すとこのタブ)。eyecam-rec が言わなくなれば自然に消える(`eyecam::toolState` / `toolNotice`。`--fake-eyecam …:outdated`、`waiting:auto=too_old:setup:outdated` で描く)。左の列には、準備が済むまで「次にやること・目のカメラ」、済んだ直後に緑の「目のカメラ:準備できたよ」。済んだあとのふだんの画面: いまの状態(両目・片目・Valve の値とその理由。ヒントは「オン・オフは「基本」タブで」。カメラのスイッチ `camera_lids` は「基本」タブに移した。オフのあいだの校正のボタンは「「基本」タブで目のカメラを「使う」にしてから」)、その下の行の右に[普段の目を覚え直す](英語は Relearn my eyes。左に「約 30 秒。そのあいだも今の値で動くよ」。eyecam-rec に `relearn` を送り、覚え始めがやり直しになって、いまの状態の右のピルが「普段の目を覚えてるよ(あと N 秒)」に戻る。覚えているあいだも、もう覚えていた目は前の値でまぶた・見開き・細めを送り続ける(`calib_state` の 4 はそのまま)。押せるのは `eyecam::relearnAllowed`: eyecam-rec の状態が新しく、idle・live・目の映像あり〔`locked`〕・`baseline` を書く eyecam-rec、`camera_lids` がオン、カメラがまぶたを動かしているか覚えている途中〔`relearnUse`〕、ほかのコマンドの返事待ちでない)、違和感があるときの[目のカメラの校正(18秒)]、細めも送るときの[ユーザー校正(最初に 1 回)](どちらも ③ と同じカードで進み、終わると結果のカード)、こんなときは。見開きの出やすさは「まぶた」タブ。`--fake-eyecam` と `--fake-password`、`--fake-camera` で描く | -| 詳細 | いちばん上の切り替え(バージョン / 困ったとき / 動作確認 / ファイル。英語は Version / Having trouble / Debug tools / Files)で 1 ページずつ出す。初めて開いたときは「バージョン」、そのあとは最後に開いた小タブ(パネルを起動し直しても。下の「最後に開いた小タブ」)。ほかのタブから戻っても同じ小タブ。新しい版があるあいだは「バージョン」の後ろに小さな点。「バージョン」: 今の版(大きく)と、最後に確かめた時刻・結果(「21:10 に確認・新しい版 0.7.6 があります」。確かめられなかった・更新に失敗したときは赤で、理由は 4 行、くわしくの場所は 2 行まで)。新しい版があれば右に[0.7.6 に更新する](確認を 1 回はさむ)、その下の箱に「0.7.6 の内容」としてその版の要約を 4 行まで(日本語の画面ではリリースの「日本語:」の段落、無ければ英語。要約の無いリリースなら箱を出さない)。更新に失敗したら[もう一度][閉じる]、入れ終わったら[閉じる]。いちばん下の行に[更新履歴](下を参照)、「起動時と 1 時間ごとに確認 オン / オフ」のチップ(押すと切り替わる)、右に[今すぐ確かめる](確かめている間と更新中は押せない)。カードのいちばん下に「ささけん@の Frame アプリ」の行(下の「ほかの Frame アプリ」)。「困ったとき」: 「診断」の行(「困ったら、診断の画面のスクショを送ってね」、診断コードのチップ、[開く]で下の「診断」ページ)、「最近の記録」(新しい 3 件。時刻・何をしたか・短い理由・OK / 失敗 / 片目だけ・[見る]。下の「記録」)、「SSH なら frameeyeosc-panel --report latest」と[すべての記録(N)›]。「動作確認」: 視線の点を表示(デバッグ用、オン / オフ)と点の距離(0.3〜2.0 m、0.1 m 刻み。1.2 m くらいより奥だと開いたダッシュボードに隠れる。点がオフの間は灰色)、そのすぐ下の行に「倍率の前の向き(輪)」(ヒント「目が見ている向き」。英語は Ring: before the scale / Where your eyes look。オン / オフは点のスイッチと同じ幅で真下に並び、右に「視線の倍率が 100 % でないときだけ、点のまわりに輪で出ます」〔英語は Shown around the dot only while a gaze scale isn't 100 %〕を 2 行まで。点がオフの間は選んでいる側を枠だけにして灰色、押せない)、目のログ(ボタンの右に保存先と 60 分の上限、目のカメラを使っているときは「目のカメラの数字(映像なし)も ~/eyecam/drift/ に残します」も。記録できなかったらその理由を 3 行まで。目のカメラを使っていて eyecam-rec が driftlog を持っていれば、記録の間 eyecam のドリフトの記録も取る: 始めに `driftlog on eyes_日時`、終わりに `driftlog off`。記録の途中で eyecam-rec が起き直してドリフトの記録が止まったら、もう一度 `driftlog on`〔断られたら 30 秒おき〕。recorder.h の DriftLink)、eyecam-rec が動いているあいだは「開発用」のバッジ付きの「目の撮影」([撮影開始]で下の光の注意。ボタンの右に「目の映像が ~/eyecam/rec_日時/ に残るよ(1 回で約 2.5 GB)」)、その下に「AI の学習に協力する」(ヒント「半目の読み直し(AI)のための録画」、[開く]。目のカメラの準備が済むまでは押せず、右に「目のカメラの準備が済むと使えるよ」。準備のあとは右に、始める前は「目の映像を録って開発者に送る手伝い(任意・約 10.5 GB)」、途中は「1 / 4 回録れたよ。続きからできるよ」、録り終わったら「全部録れたよ。まだなら開発者に送ってね」。下の「AI の学習に協力する」)。「ファイル」: 設定ファイル・キャリブレーション・状態ファイル・記録の場所、本体の PID と動いている時間、コマンドで固定中の項目と今の値(3 行まで) | +| 詳細 | いちばん上の切り替え(バージョン / 困ったとき / 動作確認 / ファイル。英語は Version / Having trouble / Debug tools / Files)で 1 ページずつ出す。初めて開いたときは「バージョン」、そのあとは最後に開いた小タブ(パネルを起動し直しても。下の「最後に開いた小タブ」)。ほかのタブから戻っても同じ小タブ。新しい版があるあいだは「バージョン」の後ろに小さな点。「バージョン」: 今の版(大きく)と、最後に確かめた時刻・結果(「21:10 に確認・新しい版 0.7.6 があります」。確かめられなかった・更新に失敗したときは赤で、理由は 4 行、くわしくの場所は 2 行まで)。新しい版があれば右に[0.7.6 に更新する](確認を 1 回はさむ)、その下の箱に「0.7.6 の内容」としてその版の要約を 4 行まで(日本語の画面ではリリースの「日本語:」の段落、無ければ英語。要約の無いリリースなら箱を出さない)。更新に失敗したら[もう一度][閉じる]、入れ終わったら[閉じる]。いちばん下の行に[更新履歴](下を参照)、「起動時と 1 時間ごとに確認 オン / オフ」のチップ(押すと切り替わる)、右に[今すぐ確かめる](確かめている間と更新中は押せない)。カードのいちばん下に「ささけん@の Frame アプリ」の行(下の「ほかの Frame アプリ」)。「困ったとき」: 「診断」の行(「困ったら、診断の画面のスクショを送ってね」、診断コードのチップ、[開く]で下の「診断」ページ)、「最近の記録」(新しい 3 件。時刻・何をしたか・短い理由・OK / 失敗 / 片目だけ・[見る]。下の「記録」)、「SSH なら frameeyeosc-panel --report latest」と[すべての記録(N)›]。「動作確認」: 視線の点を表示(デバッグ用、オン / オフ)と点の距離(0.3〜2.0 m、0.1 m 刻み。1.2 m くらいより奥だと開いたダッシュボードに隠れる。点がオフの間は灰色)、そのすぐ下の行に「倍率の前の向き(輪)」(ヒント「目が見ている向き」。英語は Ring: before the scale / Where your eyes look。オン / オフは点のスイッチと同じ幅で真下に並び、右に「視線の倍率が 100 % でないときだけ、点のまわりに輪で出ます」〔英語は Shown around the dot only while a gaze scale isn't 100 %〕を 2 行まで。点がオフの間は選んでいる側を枠だけにして灰色、押せない)、目のログ(ボタンの右に保存先と 60 分の上限、目のカメラを使っているときは「目のカメラの数字(映像なし)も ~/eyecam/drift/ に残します」も。記録できなかったらその理由を 3 行まで。目のカメラを使っていて eyecam-rec が driftlog を持っていれば、記録の間 eyecam のドリフトの記録も取る: 始めに `driftlog on eyes_日時`、終わりに `driftlog off`。記録の途中で eyecam-rec が起き直してドリフトの記録が止まったら、もう一度 `driftlog on`〔断られたら 30 秒おき〕。recorder.h の DriftLink)、eyecam-rec が動いているあいだは「開発用」のバッジ付きの「目の撮影」([撮影開始]で下の光の注意。ボタンの右に「目の映像が ~/eyecam/rec_日時/ に残るよ(1 回で約 2.5 GB)」)、その下に「細めの学習用に撮る」(ヒント「半目の読み直し(AI)の学習用・任意」、[撮る]と[録ったもの]〔フォルダがあるときだけ〕。目のカメラの準備が済むまではどちらも押せず、右に「目のカメラの準備が済むと使えるよ」。準備のあとは右に、1 本目の前は「目の映像を約 1 分半録って、開発者に送る手伝い(1 本 約 2.7 GB)」、そのあとは「これまで 3 本(8.2 GB)」。下の「細めの学習用に撮る」)。「ファイル」: 設定ファイル・キャリブレーション・状態ファイル・記録の場所、本体の PID と動いている時間、コマンドで固定中の項目と今の値(3 行まで) | - 目のプレビュー(`eye_preview.{h,cpp}`): 左の列の「目のプレビュー」で、タブの中身の上にシートを開く(ほかの Frame アプリの一覧と同じ枠と ✕。タブは並んだままで、どれも選ばれていない見た目になり、どのタブを押しても閉じる。左の列のお知らせを押しても、ダッシュボードを閉じても閉じる)。`--preview-sheet` で開いて描く - 上に「目のプレビュー」と ✕。その下の 1 行: 「送る値 / 生の値 / カメラの値」(どの値で目を描くか)、「鏡で見る」(既定オン: 鏡に映した向き。右目を右、左目を左に描き、右を見ると虹彩も右へ。オフでほかの人〔正面のアバター〕から見た向き: 右目を左に描き、左右の視線を反転)、「カメラ映像」(最初はオフ。うっかり目が映らないように。一度オンにすると、パネルが動いているあいだは開くたびにオンのまま。オフにするか、カメラが使えなくなると解除)、[止める]/[動かす](その時点の値と映像のまま止める)。スイッチは、オンで ✓ とアクセントの薄い塗り・太字。選んだものはパネルを起動しているあいだ覚えている(止める・動かすは開くたびに解除) @@ -203,7 +203,7 @@ ssh steamos@ 'cd ~/frameeyeosc-panel && cmake -G Ninja -S . -B bui ./build/text-test # テンキーで打った送り先の IP の確認と、目合わせの失敗の文(日本語・英語)のテスト ./build/sounds-test # 目合わせの音の WAV と、どの場面でどの音かのテスト(鳴らさない) ./build/changelog-test # 更新履歴の読み方(英語と日本語の形、「日本語:」を飛ばす、最初の文、マークダウン、英語で補う)のテスト -./build/contrib-test # 「AI の学習に協力する」(ステップと大きさ、eyecam のプロトコルのファイルとの照らし合わせ、info.txt、録画の終わりとかぶり直しの見分け、画面、tar の見出し、$TMPDIR の中で作る・移す・まとめて tar と zstd で戻す・消す、最初から最後まで)のテスト +./build/contrib-test # 「細めの学習用に撮る」(1 本の大きさ、eyecam の protocol_contrib.txt と各段の文、info.txt の節、外したかの見分け、録画の終わり、画面、tar の見出し、$TMPDIR の中で移す・校正結果・まとめて tar と zstd で戻す・ファイルの足りない録画を外す・消す、続けて撮る流れ)のテスト ./build/eyecam-test # 目の撮影タブ(状態ファイル、出す・消す条件、指示の文、全面の光とそのフェード、光の注意と送るコマンド、/tmp の偽ソケット)のテスト ./build/report-test # 記録(ジャーナルと Valve のログの読み方、主な行とその言葉、summary.json、report.txt、作り物のログからの記録、10 件、大きさの上限、--report)と最後の小タブのテスト ./build/file-io-test # ファイルをまとめて読む file_io(上限、NUL、4 KiB の境目、無いファイル)のテスト @@ -243,7 +243,7 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - `--report-make 種類 始め 終わり 結果 理由`(確かめるためのもの): その時間のジャーナルと Valve のログから、パネルが走り終わったあとに書くのと同じ記録を `--reports-dir` に書き、フォルダを出す(例: `--report-make fit "2026-10-06 20:45:09" "2026-10-06 20:45:17" failed "…"`。条件と診断コードは空) - 更新履歴は `--history` で開き、`--history-open 版` でその版の行を開き、`--history-scroll px` でスクロールして描く(どちらも `--history` を含む。スクロールは中身の範囲に収める)。変更履歴は上の場所から探す。`--changelog-dir DIR` でそのフォルダだけを見る(無いフォルダなら「見つかりません」の見た目)。`--fake` とも一緒に使える - `--click X,Y`(何回でも)は、描く前にその座標を押したことにする。当たり判定と設定ファイルの書き込みをヘッドセットなしで確かめる用(`--fake` とは一緒に使えない。`--config` の設定ファイルを本当に書き換えるので、試すときは別の場所を指定する) -- 「AI の学習に協力する」(下)は `--fake-contrib 状態` で、作り物の協力(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意の画面)・`nospace`(空きが足りない)・`deleted`(消したあとの同意の画面)・`rewear`(2 回目の前のかぶり直し)・`rewear-off`(外したのが分かった。[かぶり直した]が押せる)・`ready`(2 回目を始める)・`ready-free`(ふだん使いを始める)・`running`・`running-free`(録画中)・`failed`・`failed-error`(最後まで録れなかった)・`done`(録り終わり)・`packing`(まとめている 45 %)・`packed`(まとめた)・`extra`(任意のふだん使い)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」のその状態の行を描く。何も読み書きしない +- 「細めの学習用に撮る」(下)は `--fake-contrib 状態` で、作り物のフォルダ(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意)・`deleted`(消したあとの同意)・`eyewear`(眼鏡・インサートの質問)・`ready`(撮る前。これまで 3 本)・`nospace`(空きが足りない)・`running`(録画中: 2 回目の細め)・`failed`(途中で止まった)・`incomplete`(ファイルが足りなかった)・`done`(撮れた: 4 本)・`list`([録ったもの]。1 本はファイルが足りずにまとめから外れた)・`packing`(まとめている 45 %)・`packed`(まとめた)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」の行を描く(`none` で 1 本目の前)。何も読み書きしない - 目の撮影タブ(下)は `--fake-eyecam waiting|idle|confirm|searching|recording:ラベル|error` と `--tab eyecam` で描く(`confirm` は idle で光の注意を開いたところ。`recording:ラベル:unlocked` でカメラが目を見失ったとき=「HMD をかぶってください」、`recording:ラベル:nolight` で光なしのプロトコル=「光なし」。`:pupil=0.97,0.04` で目ごとの瞳の見え具合(`null` も可。無ければ古い eyecam-rec)、`:failed=L|R|LR|mixed`(`:prov`)で片目だけ失敗した校正や両目の失敗のメッセージ。`:page=user:lookdown=L|R|LR` で、下を見たときの補正をその目(LR は両目)だけ標準の値にして通ったユーザー校正のカード、`:lookprev=L|R|LR` で前の値のままにして通ったカード(`lookdown=R:lookprev=L` で目ごとに違うとき)。`:search=not_worn|no_video|one_eye` で映像が見つからない理由(`status.json` の `search`。`not_worn` は近接センサー 12)。ラベルは lead_in・normal・widen・close・squint・look_up・look_down・bright・dark・end。recording は eyecam の `protocol_widen.txt`(17 ステップ・83 秒。`nolight` なら `protocol_widen_nolight.txt` の 14 ステップ)でそのラベルが最初に出るステップになり、その手順をステップのチップにも使う(ファイルは読まず、同じ手順を main.cpp に持っている)。error は失敗した返事も出す)。`--eyecam-fill-png PATH`(`--eyecam-fill bright|dark`、`--language`)で全面の光の画像。`--eyecam-dir DIR` で eyecam-rec のフォルダ(`status.json` と `ctl.sock`)を別の場所にする。`--fake` なしの `--dump-png` はそこを読み、`--click` で[撮影開始]→[光ありで始める]/[光なしで始める]や[中止]を押すとそのソケットに本当に送って返事を待つ(試すときは一時フォルダに偽の `status.json` とソケットを置く。本物の `/run/user/1000/eyecam/` には何も作らない) - `--probe` は Background 型でつなぐだけで、オーバーレイも Vulkan も作らない。`FindOverlay`・名前・幅・閉じるボタン・表示中か・`GetOverlayTextureSize` を出す - `contrib/icons/frameeyeosc-panel-{48,128,256}.png` は `--thumbnail-png` で書き出したもの(ダッシュボードのサムネイルと同じ絵) @@ -260,24 +260,24 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - **フェード**: 出るときは `SetOverlayAlpha` を 0 から 1 へ 0.7 秒かけて上げる(0 にしてから `ShowOverlay`)。録画が続いたままステップが変わったときは 0.5 秒かけて 0 へ下げてから隠す。明るい↔暗いが続くときも白から黒へ一気に変えず、今の光を 0.5 秒で消してから次の光を 0.7 秒で出す。アルファは前の周からの経過時間で動かす(`eyecam::stepLight`。1 回に進める時間は 0.1 秒まで、なのでループが詰まっても一気に明るくならない)。フェードの間はループを表示のフレームごと(約 90 Hz)に回す - **すぐ隠す**: 状態ファイルが 1 秒より古い・タブが消えた・録画していない(止まった・失敗した・idle に戻った)・[中止]を送った(返事を待たずに)のどれかなら、フェードせずにその周で隠す(`eyecam::hideLightAtOnce`)。パネルの終了時(どの終わり方でも)は真っ先に隠し、終了処理で `DestroyOverlay` する。プロセスが落ちたときは SteamVR が消す -### AI の学習に協力する(contrib) - -「半目の読み直し(AI)」(eyecam のまぶたの見直しの CNN)を、いろんな人の目で学習させるための録画を、パネルだけで最後まで進められるようにしたもの。開発者に送ってもらうのは、eyecam の光なしの撮り方 3 回(かぶるたびに 1 回)とふだん使い 3 分。録画そのものは「目の撮影」と同じく eyecam-rec が `~/eyecam/rec_日時/` に書き(`cues.csv`・`valve.csv`・`frames.csv`・`meta.txt` もそのまま)、終わったものをパネルが協力のフォルダに移す。eyecam-rec は変えていない(`start widen_nolight` と `start free` を送るだけ。どちらのプロトコルのファイルも install.sh が eyecam-rec の横に置く)。 - -- **入り口**: 「詳細」›「動作確認」の「目の撮影」の下の「AI の学習に協力する」の[開く](上の表)。目の撮影と同じく eyecam-rec が動いているあいだだけ出て、押せるのは目のカメラの準備が済んでから(`eyecam::setUp`)。「開発用」のバッジは付けない(使う人向け)。まぶたタブの「半目の読み直し(AI)」の行は使う人の設定なので、10 GB の録画の入り口はそこに置かない -- **シート**: 詳細タブの中身の代わりに出る(タブの列と左の列はそのまま。どのタブ・小タブを押しても閉じる。ダッシュボードを閉じても開いたまま)。上に「AI の学習に協力する」とヒント、右に × の[閉じる]。同意のあとは × の左に小さな[この録画を消す](かぶり直し・始める・録れなかった画面)、その下にステップのチップ(決まった動き 1〜3・ふだん使い。済んだもの: チェック、次: アクセント色。任意のふだん使いは頼んだか録ったときだけ「ふだん使い(追加)」)。画面は `contrib::screen` が決める: - - **同意**: 「「半目の読み直し(AI)」を、いろんな人の目で学習させるための録画だよ。協力するかどうかは自由です。」、左に見出しで「録るもの」(目のカメラの映像、約 1 分を 3 回とふだん使い 3 分。全部で約 10.5 GB〔目のカメラの毎秒の枚数から。分からなければ 90〕。送るまでは Frame の中だけ)・「使いみち」(半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く)・「消してほしいとき」(言えば消す。もう配った AI に入った分は取り出せない)。その下に灰色で「画面が白や黒に光る段はないよ(光なしで撮るよ)。全部で 15 分くらい。途中でやめても、あとで続きからできるよ。」(光の注意は要らない)、ボタンの上に「空き容量 23.2 GB(要るのは約 11.5 GB)」(録画の分と 1 GiB の余り。足りなければ赤で「空きが足りないよ。あと 4.1 GB 空けてね」で[協力する]は押せない)。[やめる](閉じる)と[協力する](アクセント)。消したあとは上に緑で「録画を消したよ」 - - **かぶり直し**(2 回目から、始める前): ステップの名前(「決まった動き(2 / 3)」「ふだん使い(3 分)」)、アクセントの枠の箱に大きく「HMD をいったん外して、もう一度かぶってね」、「かぶるたびにカメラの映り方が少し変わるのが、学習に大事なんだ。」、点と「かぶり直したら、またこの画面を開いてね。外したのも、かぶったのも自動で分かるよ。」(外したのが分かったら、アクセントの点と太字で「外したのが分かったよ。かぶって少し待ってね」)。右下に[かぶり直した]: 待ち始めて 20 秒(`kFallbackSec`)たってから押せる(それまで左に「かぶり直してから押せるようになるよ」、そのあと「自動で分からないときは、かぶり直してからこれを押してね」) - - **始める**: ステップの名前と、かぶり直しのあとなら緑のチェックで「かぶり直したのが分かったよ」。決まった動きは「ビープと画面の指示に合わせて、目を閉じる・普通・見開くを 3 回、そのあと細める・上を見る・下を見るをするよ。約 1 分。」と[撮影をはじめる(約 1 分)]。ふだん使いは点つきで「いつもどおり VR を使ってね。ダッシュボードは閉じて大丈夫」「3〜4 回、左右の耳のほうを 3 秒くらい見てね」「何回か、目を開けたまま下(手元)を見てね」「話したり笑ったりして大丈夫。まばたきも自然にね」と灰色で「始まりにビープが 1 回、終わると低いビープが 2 回鳴るよ」、[3 分の撮影をはじめる]。任意のふだん使いは左に[戻る]。押せるのは eyecam-rec が待機中(かエラー)・コマンドの返事待ちでない・まとめていない・その録画と 1 GiB が空いているとき(`contrib::startAllowed`)。押せないときはボタンの上に赤で「空きが足りないよ(約 2.8 GB 要る)」か灰色で「目のカメラがほかのことをしているあいだは待ってね」 - - **録画中**: eyecam-rec が探している・録っているあいだは目の撮影の画面(`drawRun`)のまま(その前後のつなぎ目は見出しと「送っています…」だけ)、見出しだけ「AI の学習に協力する・決まった動き 2」。ふだん使いはステップのチップを出さず、指示を「ふだんどおり使ってね」にする(あと何秒・バー・[中止]は同じ)。シートを閉じていても、詳細タブの録画の画面は同じ見出しになる - - **録れなかった**: ステップの名前、赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった)、途中までのフォルダがあれば灰色で「途中までの分は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度録る](かぶり直しはやり直さない) - - **録り終わり**: 緑のチェックと「全部録れたよ。ありがとう!」、「合計 10.5 GB」、灰色で「場所: ~/eyecam/contrib_日時/」、「1 つのファイルにまとめて、Google ドライブなどで開発者に送ってね。」。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.9 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(6.5 GB)」とその下にファイル名(まとめるボタンはもう押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。下の左に[この録画を消す]、右に[もう 1 回ふだん使いを録る(任意)](録ったら出さない) - - **消す確認**: 赤い枠のカードで「この録画を消す?」「~/eyecam/contrib_日時/ を中身ごと(10.5 GB)消します。元に戻せないよ。開発者に送ったあとで消してね。」、[やめる](アクセント)と[消す]。消している間は「消しています…」 -- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib_日時/` のうち `info.txt` が読めるいちばん新しいものを読む。[協力する]でそのフォルダと `info.txt` を作る。[撮影をはじめる]は `start widen_nolight` か `start free` を `ctl.sock` に送り、`info.txt` に `running` と送った時刻を書く(パネルが途中で起動し直しても、その時刻から追い続ける)。終わりは `status.json` だけで見る(`contrib::RunWatch`): そのプロトコルの `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだ録画は `rename` で協力のフォルダへ、止まった・失敗したものの途中までは `aborted/` へ移す(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない) -- **かぶり直しの見分け**(`contrib::WearWatch`。ダッシュボードを閉じていても `status.json` を読むたびに見る): 外した=eyecam-rec が待機中でライブ処理がオンのときに目の映像が 3 秒止まった(`locked` が false)、近接センサーが「かぶっている」を読んだあとで 3 秒「かぶっていない」を読んだ(顔によっては、かぶっていても閾値に届かないので、一度も「かぶっている」を読まなければ数えない)、またはパネルのループが 3 秒以上止まった(HMD を外すと Frame がスリープする。CLOCK_BOOTTIME で測る)。かぶった=そのあと目の映像が 2 秒戻った(ライブ処理がオフならセンサーが 2 秒「かぶっている」)。分かったら `info.txt` の `next_wear=detected`、[かぶり直した]なら `button`、済んだステップには `stepN_wear` として残す(1 回目は `first`) -- **フォルダ**: `~/eyecam/contrib_日時/` に `info.txt` と、済んだステップの `rec_日時/`(eyecam-rec が書いたまま)、`aborted/rec_日時/`(途中まで。まとめるファイルには入れない)、まとめたファイル `contrib_日時.tar.zst`(zstd が無ければ `.tar`)。`info.txt` は `key=value`: `format`・`app_version`・`consent`(時刻とタイムゾーン)・`stepN_protocol` / `_rec` / `_bytes` / `_wear` / `_done`(N は 1〜5)・`next_wear`・`running` / `running_since`・`aborted`・`packed` / `packed_bytes`。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) -- **まとめる**(`contrib::Worker`、別のスレッド、nice 10): `info.txt` と済んだ録画だけを ustar の tar にして(8 GiB 以上の大きさは GNU の base-256)、zstd があれば `zstd -3 -T2` に通す(Frame の目の映像は 0.62 ほどになる)。`.part` に書いて fsync してから名前を変え、失敗したら `.part` を消す。始める前に空きを確かめる(録画の 0.75 倍〔zstd なし: 1 倍〕と 1 GiB)。元の録画には触らない -- **消す**: 確認のあと、`~/eyecam` の直下の `contrib_日時` だけを(中のリンクはたどらずに)丸ごと消す。消したら同意の画面に戻る +### 細めの学習用に撮る(contrib) + +「半目の読み直し(AI)」(eyecam のまぶたの見直しの CNN)が細めや半目を見分けられるように、学習用の目の映像を撮ってためておき、開発者に送ってもらうためのもの。ボタンを 1 回押せば最後まで進む: 初めて(と同意文の版が上がったとき)だけ同意と眼鏡・インサートの質問、そのあと eyecam の `protocol_contrib.txt`(約 93 秒。全体の光なし)を 1 回撮り、終わりの画面を出す。録画そのものは「目の撮影」と同じく eyecam-rec が `~/eyecam/rec_日時/` に書き(`cues.csv`・`valve.csv`・`frames.csv`・`meta.txt` もそのまま)、終わってその 4 つがそろっていれば、パネルが `~/eyecam/contrib/` に移して `info.txt` に 1 本ぶん書き足す。何回でも押せて、録画はそのフォルダにたまっていく。 + +- **入り口**: 「詳細」›「動作確認」の「目の撮影」の下の「細めの学習用に撮る」の[撮る]と[録ったもの](上の表)。目の撮影と同じく eyecam-rec が動いているあいだだけ出て、押せるのは目のカメラの準備が済んでから(`eyecam::setUp`)。「開発用」のバッジは付けない(使う人向け)。まぶたタブの「半目の読み直し(AI)」の行は使う人の設定なので、録画の入り口はそこに置かない +- **シート**: 詳細タブの中身の代わりに出る(タブの列と左の列はそのまま。どのタブ・小タブを押しても閉じる。ダッシュボードを閉じても開いたまま)。上に「細めの学習用に撮る」とヒント、右に × の[閉じる]。[撮る]で開くたびに、前の終わり・失敗の画面は忘れる。画面は `contrib::screen` が決める: + - **同意**(`info.txt` に同意が無い・同意文の版〔`contrib::kConsentVersion`、今は 1〕が古い・眼鏡の答えが無いとき): 「「半目の読み直し(AI)」が細めや半目をもっと正しく見分けられるように、学習用の目の映像を録るよ。協力するかどうかは自由です。」、見出しつきで「録るもの」(目のカメラの映像を 1 回約 1 分半〔1 本 約 2.7 GB〕。送るまでは Frame の中だけ)・「使いみち」(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く)・「消してほしいとき」(言えば消す。もう配った AI に入った分は取り出せない)、警告色の枠で送り方の注意「目の映像は GitHub の issue や公開の場所に上げないでね。開発者に非公開で送ってね(送り方は開発者が伝えます)。」(連絡先はアプリに入れない)、灰色で「画面が白や黒に光ることはないよ(光なしで撮るよ)。」(光の注意は要らない)。[やめる]と[同意して次へ]。消したあとは上に緑で「録画を消したよ」 + - **眼鏡・インサート**: 「HMD の中で、眼鏡や度付きインサートを使ってる?」「学習のときに分けて見るためだけに使うよ。録画ごとに info.txt に書くよ」、同じ重さの 4 つ[使ってない][眼鏡][度付きインサート][答えない]と[戻る]。答えたら、そのとき同意と答えを `info.txt` に書く(フォルダが無ければ作る) + - **撮る前**: 「細めの学習用の撮影(約 1 分半)」と点つきで「最初の 10 秒は、普通に開けて正面を見て待ってね」「ビープと画面の指示に合わせて、閉じる・普通・細める・普通・見開くを 3 回、そのあと上・下・左・右を見るよ」「細めるは、まぶしいときみたいに目を細める(閉じない)」「左・右は自分から見た向き。顔は正面のまま、目だけ動かしてね」「画面が白や黒に光ることはないよ」、録ったものがあれば灰色で「これまで 3 本・合計 8.2 GB」。ボタンの上に灰色で「空き容量 23.2 GB(1 本 約 2.7 GB)」(足りなければ赤で「空きが足りないよ(約 3.8 GB 要る)」、eyecam-rec がほかのことをしていれば「目のカメラがほかのことをしているあいだは待ってね」)、[撮影をはじめる(約 1 分半)]。押せるのは同意が済んで、eyecam-rec が待機中(かエラー)・コマンドの返事待ちでない・まとめていない・1 本ぶん(目のカメラの毎秒の枚数から。分からなければ 90)と 1 GiB が空いているとき(`contrib::startAllowed`) + - **録画中**: eyecam-rec が探している・録っているあいだは目の撮影の画面(`drawRun`)のまま、見出しだけ「細めの学習用に撮る」(その前後のつなぎ目は見出しと「送っています…」だけ)。ステップのチップは `protocol_contrib.txt` から(23 ステップ)。左右を見る段は「左の耳の方を見て」「右の耳の方を見て」(チップは「左を見る」「右を見る」)。シートを閉じていても、詳細タブの録画の画面は同じ見出しになる + - **録れなかった**: 赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった・ファイルが足りなかった〔そのファイル名〕)、途中までのフォルダがあれば灰色で「途中までの分は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度撮る] + - **終わり**(撮れたあと、または[録ったもの]): 撮れたあとは緑のチェックと「撮れたよ。ありがとう!」、[録ったもの]なら「録ったもの」。「これまで 4 本・合計 10.9 GB」、灰色で「場所: ~/eyecam/contrib/」、撮れたあとは「別の日や、かぶり直したあとにもう一度押してくれると、もっと助かるよ」、送り方の注意の枠。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.7 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(4 本・6.3 GB)」とその下にファイル名(それより本数が増えるまでは押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。まとめるときにファイルが足りなかった録画は赤で「rec_… はファイルが足りないので入れなかったよ。もう一度撮ってね」。下の左に[録画を消す]、右に[閉じる] + - **消す確認**: 赤い枠のカードで「撮った録画を全部消す?」「~/eyecam/contrib/ を中身ごと(10.9 GB)消します。元に戻せないよ。開発者に送ったあとで消してね。」、[やめる](アクセント)と[消す]。消している間は「消しています…」、消したら同意の画面(同意も `info.txt` と一緒に消える) +- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib/info.txt` を読む。[撮影をはじめる]は `ctl.sock` に `start contrib` を送り、そのときの値を覚える(下の `info.txt`)。終わりは `status.json` だけで見る(`contrib::RunWatch`): `contrib` の `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだもので `frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろっていれば `rename` でフォルダへ移し、`~/eyecam/calib_日時/calib_result.json` のいちばん新しいもの(撮り始めたときに読んだもの)を `rec_日時.calib_result.json` として隣に置く。足りない・止まった・失敗したものは `aborted/` へ(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない)。パネルが録画の途中で起動し直したときは追わない(その録画は `~/eyecam/rec_日時/` に残る) +- **info.txt**(`~/eyecam/contrib/info.txt`): 初めに何のファイルかのコメントと `format=2`、そのあと出来事ごとに節を書き足す。`[consent]`(`time`・`version`・`eyewear`: `none` / `glasses` / `inserts` / `no_answer`。最後の節が効く)、`[recording]`(`rec`・`protocol`・`start`・`start_unix`・`end_unix`・`bytes`・`app_version`・`eyecam_rec_sha256`〔入っている eyecam-rec の SHA-256 の先頭 8 桁。git のハッシュは持っていない〕・`lid_veto`(`on` / `off` / `unknown`)・`widen`・`widen_sensitivity`・`camera_lids`・`eyewear`・`camera_fps`・`minutes_since_previous`(前の録画が終わってから何分。1 本目は `none`)・`headset_off`(そのあいだに HMD を外したか: `yes` / `no` / `unknown` / 1 本目は `none`)・`headset_off_count_start` / `_end`・`eyecam_started_unix`・`calib_result`)、`[aborted]`(`rec`・`why`)、`[packed]`(`file`・`recordings`・`bytes`)。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) +- **外したかの見分け**: eyecam-rec の `status.json` の `headset_off_count`(起動してから、目の映像が 10 秒以上止まったか近接センサーが 10 秒以上「かぶっていない」になって、そのあとまたかぶった回数。サスペンドも数える時計で測る。tools/eyecam の README)と `started_unix` で決める。前の録画の終わりと今回の始まりで eyecam-rec が同じ起動(`started_unix` が同じ)なら、数が増えていれば `yes`、同じなら `no`。eyecam-rec が起動し直していた・古い eyecam-rec で値が無いときは `unknown`。続けて 2 回押しただけの録画を別のかぶりとして数えないため +- **まとめる**(`contrib::Worker`、別のスレッド、nice 10): `info.txt`、4 つのファイルがそろった録画とその校正結果だけを ustar の tar(中のフォルダ名は `contrib/`。8 GiB 以上の大きさは GNU の base-256)にして、zstd があれば `zstd -6 -T2` に通し、`contrib_日時.tar.zst`(無ければ `.tar`)にする。Frame の目の映像 2 GB で測ると、zstd -3 は 0.62 倍・7.7 秒、-6 は 0.58 倍・20 秒、-9 は 0.56 倍・43 秒、-12 は 0.55 倍・90 秒、zip -6 は 0.60 倍・166 秒、gzip -1 は 0.62 倍・19 秒だったので、-6 にした(SteamOS には zstd も tar も zip も入っている。7z は無い)。`.part` に書いて fsync してから名前を変え、失敗したら `.part` を消す。始める前に空きを確かめる(録画の 0.7 倍〔zstd なし: 1 倍〕と 1 GiB)。できたら `[packed]` を書き足し、前にまとめたファイル(本数の少ないもの)は消す。元の録画には触らない +- **消す**: 確認のあと、`~/eyecam/contrib` だけを(中のリンクはたどらずに)丸ごと消す ## ほかのアプリから動かす(--action) @@ -410,7 +410,7 @@ systemctl --user daemon-reload | `src/config.*` | `config.json` の項目の表(型・既定値・範囲・刻み)、読み書き(一時ファイル → fsync → rename) | | `src/status.*` | `status.json` の読み込みと、本体が動いているかの判断 | | `src/eye_preview.*` | 目のプレビュー: どの値で両目を描くか、まぶた・瞳孔・視線の形、eyecam-rec の `preview` ファイルの読み方(mmap・seqlock・確かめ)、映像が出せるか、`preview on` / `off` の送り方、`--fake-preview` の作り物 | -| `src/contrib.*` | 「AI の学習に協力する」: ステップと大きさ、`info.txt` の読み書き、録画の終わりとかぶり直しの見分け(`status.json` から)、録画をフォルダに移す、tar と zstd でまとめる・消すスレッド、画面の決め方 | +| `src/contrib.*` | 「細めの学習用に撮る」: 1 本の大きさ、`info.txt` の節の読み書き、録画の終わり(`status.json` から)、4 つのファイルの確認と移動、外したかの見分け、tar と zstd でまとめる・消すスレッド、画面の決め方 | | `src/eyecam.*` | 目の撮影タブ(開発用): eyecam-rec の `status.json` の読み込み、タブと全面の光を出す条件と光のフェード、光の注意(始める前の確認と送るコマンド)、指示の文、`ctl.sock` への送信 | | `src/action_socket.*` | `--action`: 動作ごとの判断と終了コード、1 行のやりとり、コマンドの側(つないで 2 秒待つ)、パネルの側のソケット(ノンブロッキング、uid の確認) | | `src/autostart.*` | `systemctl --user` で自動起動を読む・切り替えるワーカースレッド | diff --git a/panel/src/contrib.cpp b/panel/src/contrib.cpp index 6d60612..1d04be7 100644 --- a/panel/src/contrib.cpp +++ b/panel/src/contrib.cpp @@ -1,6 +1,6 @@ -// "Help train the AI": the contribution's progress (info.txt), which step comes next and whether it fits, following its -// recordings and the re-wear between them in eyecam-rec's status, moving the recordings into its folder, and packing -// or deleting it on a thread of its own. +// "Record for squint training": its info.txt (sections added for the consent, each recording, each try that didn't +// finish and each pack), whether a recording fits, following it in eyecam-rec's status, checking its files and moving +// it into the folder, and packing or deleting the folder on a thread of its own. #include "contrib.h" #include "file_io.h" @@ -20,7 +20,6 @@ #include #include #include -#include #include #include #include @@ -35,16 +34,20 @@ namespace { constexpr size_t kChunk = 1 << 20; /** The packing thread's and zstd's niceness (the panel and VR come first). */ constexpr int kNice = 10; +/** The most of info.txt that is read (a section is under 1 KB). */ +constexpr size_t kMaxInfoBytes = 4 << 20; +/** The most of a calibration result that is copied. */ +constexpr size_t kMaxCalibBytes = 1 << 20; /** - * Whether text from a position is "YYYY-MM-DD_HH-MM-SS", and nothing after it. + * Whether text from a position is "YYYY-MM-DD_HH-MM-SS". * @param text the text * @param at where it starts * @return true if it is */ bool stampAt(const std::string& text, size_t at) { static const char* const shape = "dddd-dd-dd_dd-dd-dd"; - if (text.size() != at + 19) return false; + if (text.size() < at + 19) return false; for (size_t i = 0; i < 19; ++i) { const char c = text[at + i]; if (shape[i] == 'd' ? !std::isdigit(static_cast(c)) : c != shape[i]) return false; @@ -80,6 +83,16 @@ bool isRealDir(const std::string& path) { return ::lstat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode); } +/** + * Whether a path is a regular file itself (not a link to one). + * @param path the path + * @return true if it is + */ +bool isRealFile(const std::string& path) { + struct stat st {}; + return ::lstat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode); +} + /** * The last part of a path. * @param path the path (without a trailing "/") @@ -114,6 +127,32 @@ long long readInteger(const std::string& text, long long fallback) { return errno == 0 && end != nullptr && *end == '\0' ? value : fallback; } +/** + * A number from info.txt. + * @param text the value + * @param fallback for anything else + * @return the number + */ +double readNumber(const std::string& text, double fallback) { + if (text.empty()) return fallback; + char* end = nullptr; + const double value = std::strtod(text.c_str(), &end); + return end != nullptr && *end == '\0' ? value : fallback; +} + +/** + * A number as info.txt writes it. + * @param value the number (NaN: "unknown") + * @param decimals how many decimals + * @return the text + */ +std::string number(double value, int decimals) { + if (!std::isfinite(value)) return "unknown"; + char text[64]; + std::snprintf(text, sizeof(text), "%.*f", decimals, value); + return text; +} + /** * Write all of a buffer, to a file or (without SIGPIPE) to a socket. * @param fd where @@ -134,6 +173,25 @@ bool writeAll(int fd, const char* data, size_t size, bool socket) { return true; } +/** + * Write a whole small file (a temporary one, then renamed over it). + * @param path where + * @param text what + * @return true if written + */ +bool writeFile(const std::string& path, const std::string& text) { + const std::string temp = path + ".tmp"; + const int fd = ::open(temp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + if (fd < 0) return false; + const bool ok = writeAll(fd, text.data(), text.size(), false) && ::fsync(fd) == 0; + ::close(fd); + if (!ok || std::rename(temp.c_str(), path.c_str()) != 0) { + ::unlink(temp.c_str()); + return false; + } + return true; +} + /** * Put an octal number into a tar header field: digits, then a NUL. * @param field the field @@ -150,156 +208,178 @@ void putOctal(char* field, size_t width, unsigned long long value) { } // namespace -const char* protocolOf(int step) { - return isFree(step) ? kFreeProtocol : kGuidedProtocol; +const char* eyewearName(Eyewear eyewear) { + switch (eyewear) { + case Eyewear::None: return "none"; + case Eyewear::Glasses: return "glasses"; + case Eyewear::Inserts: return "inserts"; + case Eyewear::NoAnswer: return "no_answer"; + case Eyewear::Unasked: break; + } + return ""; } -bool isFree(int step) { - return step >= 3; +Eyewear parseEyewear(const std::string& name) { + for (const Eyewear e : {Eyewear::None, Eyewear::Glasses, Eyewear::Inserts, Eyewear::NoAnswer}) { + if (name == eyewearName(e)) return e; + } + return Eyewear::Unasked; } -bool needsRewear(int step) { - return step > 0; +bool consentNeeded(const Info& info) { + return info.consentVersion < kConsentVersion || info.eyewear == Eyewear::Unasked; } -long long estimateBytes(int step, double fps) { +long long estimateBytes(double fps) { const double rate = std::isfinite(fps) && fps > 0 ? fps : kDefaultFps; - const double seconds = (isFree(step) ? kFreeSec : kGuidedSec) + kTailSec; - return static_cast(seconds * 2 * rate * kFrameBytes); + return static_cast((kProtocolSec + kTailSec) * 2 * rate * kFrameBytes); } -int nextStep(const Info& info, bool extra) { - for (int step = 0; step < kRequiredSteps; ++step) { - if (info.steps[step].rec.empty()) return step; - } - return extra && info.steps[kRequiredSteps].rec.empty() ? kRequiredSteps : kStepCount; -} - -int requiredDone(const Info& info) { - int done = 0; - for (int step = 0; step < kRequiredSteps; ++step) done += info.steps[step].rec.empty() ? 0 : 1; - return done; +bool fits(long long freeBytes, double fps) { + return freeBytes < 0 || freeBytes >= estimateBytes(fps) + kMarginBytes; } long long recordedBytes(const Info& info) { long long total = 0; - for (const Step& step : info.steps) total += step.rec.empty() ? 0 : step.bytes; + for (const Recording& r : info.recordings) total += r.bytes; return total; } -long long neededBytes(const Info& info, double fps) { - long long total = kMarginBytes; - for (int step = 0; step < kRequiredSteps; ++step) { - if (info.steps[step].rec.empty()) total += estimateBytes(step, fps); - } - return total; +long long packNeedBytes(const Info& info, bool compressed) { + const double share = compressed ? kPackShare : 1.0; + return static_cast(recordedBytes(info) * share) + kMarginBytes; } -bool fits(int step, long long freeBytes, double fps) { - return freeBytes < 0 || freeBytes >= estimateBytes(step, fps) + kMarginBytes; +std::string headerText() { + return "# frameeyeosc: eye-camera recordings for training eyecam's squint and half-shut check, made with the " + "panel's\n" + "# \"Record for squint training\" (protocol_contrib.txt). Each recording is its folder here, as eyecam-rec " + "writes it\n" + "# (cues.csv, valve.csv, frames.csv, meta.txt, eye_L.raw, eye_R.raw), with the calibration result at its " + "start beside\n" + "# it. A section is added for the consent, each recording, each try that didn't finish (in aborted/, never " + "packed)\n" + "# and each pack. headset_off: whether the headset was taken off since the recording before, from " + "eyecam-rec's\n" + "# headset_off_count (the eye video stopped or the proximity sensor read off for 10 s or more, then worn " + "again; only\n" + "# within one run of eyecam-rec, else unknown). No names or other personal data are kept.\n" + "format=2\n"; } -long long packNeedBytes(const Info& info, bool compressed) { - const double share = compressed ? kPackShare : 1.0; - return static_cast(recordedBytes(info) * share) + kMarginBytes; +std::string consentSection(const std::string& time, int version, Eyewear eyewear) { + return "\n[consent]\ntime=" + time + "\nversion=" + std::to_string(version) + "\neyewear=" + eyewearName(eyewear) + + "\n"; } -std::string formatInfo(const Info& info) { +std::string recordingSection(const Recording& r) { std::ostringstream out; - out << "# frameeyeosc: eye-camera recordings for the half-shut check (AI), made with the panel's \"Help train the " - "AI\".\n" - "# Each step's recording is its folder here, as eyecam-rec writes it (cues.csv, valve.csv, frames.csv, " - "meta.txt,\n" - "# eye_L.raw, eye_R.raw). Steps 1-3 follow the protocol without light, 4 (and the optional 5) are free " - "use, each in\n" - "# a wear of its own (wear: first / detected = seen off and on again / button = the user said so). " - "aborted/ holds\n" - "# tries that didn't finish (never packed). No names or other personal data are kept.\n"; - out << "format=1\n"; - out << "app_version=" << info.appVersion << "\n"; - out << "consent=" << info.consent << "\n"; - for (int i = 0; i < kStepCount; ++i) { - const Step& step = info.steps[i]; - const std::string key = "step" + std::to_string(i + 1) + "_"; - out << key << "protocol=" << (step.protocol.empty() ? protocolOf(i) : step.protocol.c_str()) << "\n"; - if (step.rec.empty()) continue; - out << key << "rec=" << step.rec << "\n"; - out << key << "bytes=" << step.bytes << "\n"; - out << key << "wear=" << step.wear << "\n"; - out << key << "done=" << step.done << "\n"; - } - if (!info.nextWear.empty()) out << "next_wear=" << info.nextWear << "\n"; - if (info.running >= 0) { - char since[64]; - std::snprintf(since, sizeof(since), "%.3f", info.runningSince); - out << "running=" << info.running + 1 << "\n"; - out << "running_since=" << since << "\n"; - } - for (const std::string& aborted : info.aborted) out << "aborted=" << aborted << "\n"; - if (!info.packed.empty()) { - out << "packed=" << info.packed << "\n"; - out << "packed_bytes=" << info.packedBytes << "\n"; - } + out << "\n[recording]\n"; + out << "rec=" << r.rec << "\n"; + out << "protocol=" << r.protocol << "\n"; + out << "start=" << r.start << "\n"; + out << "start_unix=" << number(r.startUnix, 3) << "\n"; + out << "end_unix=" << number(r.endUnix, 3) << "\n"; + out << "bytes=" << r.bytes << "\n"; + out << "app_version=" << r.appVersion << "\n"; + out << "eyecam_rec_sha256=" << (r.eyecamRec.empty() ? std::string("unknown") : r.eyecamRec) << "\n"; + out << "lid_veto=" << r.lidVeto << "\n"; + out << "widen=" << r.widen << "\n"; + out << "widen_sensitivity=" << r.widenSensitivity << "\n"; + out << "camera_lids=" << r.cameraLids << "\n"; + out << "eyewear=" << r.eyewear << "\n"; + out << "camera_fps=" << number(r.fps, 1) << "\n"; + out << "minutes_since_previous=" << r.sincePrevious << "\n"; + out << "headset_off=" << r.headsetOff << "\n"; + out << "headset_off_count_start=" << r.offCountStart << "\n"; + out << "headset_off_count_end=" << r.offCountEnd << "\n"; + out << "eyecam_started_unix=" << number(r.eyecamStarted > 0 ? r.eyecamStarted : NAN, 3) << "\n"; + out << "calib_result=" << r.calib << "\n"; return out.str(); } -bool parseInfo(const std::string& text, Info& info) { +std::string abortedSection(const std::string& name, const std::string& why) { + return "\n[aborted]\nrec=" + name + "\nwhy=" + why + "\n"; +} + +std::string packedSection(const std::string& file, int count, long long bytes) { + return "\n[packed]\nfile=" + file + "\nrecordings=" + std::to_string(count) + "\nbytes=" + std::to_string(bytes) + + "\n"; +} + +void parseInfo(const std::string& text, Info& info) { info = Info(); std::istringstream lines(text); std::string line; + std::string section; + std::string abortedRec; while (std::getline(lines, line)) { if (!line.empty() && line.back() == '\r') line.pop_back(); if (line.empty() || line[0] == '#') continue; + if (line.front() == '[' && line.back() == ']') { + section = line.substr(1, line.size() - 2); + if (section == "recording") info.recordings.push_back(Recording()); + if (section == "consent") { + info.consentVersion = 0; + info.consent.clear(); + info.eyewear = Eyewear::Unasked; + } + continue; + } const size_t equals = line.find('='); if (equals == std::string::npos) continue; const std::string key = line.substr(0, equals); const std::string value = line.substr(equals + 1); - if (key == "app_version") { - info.appVersion = value; - } else if (key == "consent") { - info.consent = value; - } else if (key == "next_wear") { - info.nextWear = value; - } else if (key == "running") { - const long long step = readInteger(value, 0); - info.running = step >= 1 && step <= kStepCount ? static_cast(step - 1) : -1; - } else if (key == "running_since") { - info.runningSince = std::strtod(value.c_str(), nullptr); - } else if (key == "aborted") { - info.aborted.push_back(value); - } else if (key == "packed") { - info.packed = value; - } else if (key == "packed_bytes") { - info.packedBytes = readInteger(value, 0); - } else if (key.size() > 6 && key.compare(0, 4, "step") == 0 && key[5] == '_' && key[4] >= '1' && - key[4] < '1' + kStepCount) { - Step& step = info.steps[key[4] - '1']; - const std::string field = key.substr(6); - if (field == "protocol") step.protocol = value; - if (field == "rec") step.rec = value; - if (field == "bytes") step.bytes = readInteger(value, 0); - if (field == "wear") step.wear = value; - if (field == "done") step.done = value; + if (section == "consent") { + if (key == "time") info.consent = value; + if (key == "version") info.consentVersion = static_cast(readInteger(value, 0)); + if (key == "eyewear") info.eyewear = parseEyewear(value); + } else if (section == "recording" && !info.recordings.empty()) { + Recording& r = info.recordings.back(); + if (key == "rec") r.rec = value; + if (key == "protocol") r.protocol = value; + if (key == "start") r.start = value; + if (key == "start_unix") r.startUnix = readNumber(value, 0.0); + if (key == "end_unix") r.endUnix = readNumber(value, 0.0); + if (key == "bytes") r.bytes = readInteger(value, 0); + if (key == "app_version") r.appVersion = value; + if (key == "eyecam_rec_sha256") r.eyecamRec = value == "unknown" ? std::string() : value; + if (key == "lid_veto") r.lidVeto = value; + if (key == "widen") r.widen = value; + if (key == "widen_sensitivity") r.widenSensitivity = value; + if (key == "camera_lids") r.cameraLids = value; + if (key == "eyewear") r.eyewear = value; + if (key == "camera_fps") r.fps = readNumber(value, NAN); + if (key == "minutes_since_previous") r.sincePrevious = value; + if (key == "headset_off") r.headsetOff = value; + if (key == "headset_off_count_start") r.offCountStart = readInteger(value, -1); + if (key == "headset_off_count_end") r.offCountEnd = readInteger(value, -1); + if (key == "eyecam_started_unix") r.eyecamStarted = readNumber(value, 0.0); + if (key == "calib_result") r.calib = value; + } else if (section == "aborted") { + if (key == "rec") abortedRec = value; + if (key == "why") info.aborted.push_back(abortedRec + " " + value); + } else if (section == "packed") { + if (key == "file") info.packed = value; + if (key == "recordings") info.packedCount = static_cast(readInteger(value, 0)); + if (key == "bytes") info.packedBytes = readInteger(value, 0); } } - // A recording's name that isn't one (written by hand) doesn't count: that step is done again - for (Step& step : info.steps) { - const std::string name = step.rec; - if (!name.empty() && !isRecordingName(name)) step.rec.clear(); - } - return !info.consent.empty(); -} - -bool isFolderName(const std::string& name) { - return name.compare(0, 8, "contrib_") == 0 && stampAt(name, 8); + // A recording's name that isn't one (written by hand) doesn't count + info.recordings.erase(std::remove_if(info.recordings.begin(), info.recordings.end(), + [](const Recording& r) { return !isRecordingName(r.rec); }), + info.recordings.end()); + if (!isPackName(info.packed)) info.packed.clear(); } bool isRecordingName(const std::string& name) { - return name.compare(0, 4, "rec_") == 0 && stampAt(name, 4); + return name.size() == 23 && name.compare(0, 4, "rec_") == 0 && stampAt(name, 4); } -std::string folderName(std::time_t when) { - return "contrib_" + recorder::fileStamp(when); +bool isPackName(const std::string& name) { + if (name.compare(0, 8, "contrib_") != 0 || !stampAt(name, 8)) return false; + const std::string rest = name.substr(27); + return rest == ".tar.zst" || rest == ".tar"; } std::string localTime(std::time_t when, bool zone) { @@ -315,82 +395,21 @@ std::string defaultHome() { return std::string(home != nullptr ? home : "") + "/eyecam"; } -std::string findCurrent(const std::string& home) { - std::vector found = names(home); - for (auto name = found.rbegin(); name != found.rend(); ++name) { - if (!isFolderName(*name) || !isRealDir(home + "/" + *name)) continue; - Info info; - std::string error; - if (readInfo(home + "/" + *name, info, error)) return home + "/" + *name; - } - return ""; -} - -bool readInfo(const std::string& dir, Info& info, std::string& error) { - std::string text; - const std::string path = dir + "/" + kInfoFile; - if (!file_io::read(path, text, 64 * 1024)) { - error = "can't read " + recorder::shortPath(path) + ": " + std::strerror(errno); - return false; - } - if (!parseInfo(text, info)) { - error = recorder::shortPath(path) + " has no consent"; - return false; - } - return true; -} - -bool writeInfo(const std::string& dir, const Info& info, std::string& error) { +bool appendInfo(const std::string& dir, const std::string& text, std::string& error) { const std::string path = dir + "/" + kInfoFile; - const std::string temp = path + ".tmp"; - const std::string text = formatInfo(info); - const int fd = ::open(temp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + const bool fresh = ::access(path.c_str(), F_OK) != 0; + const int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); if (fd < 0) { - error = "can't write " + recorder::shortPath(temp) + ": " + std::strerror(errno); + error = "can't write " + recorder::shortPath(path) + ": " + std::strerror(errno); return false; } - // (on the disk before the rename, so a crash never leaves an empty one) - if (!writeAll(fd, text.data(), text.size(), false) || ::fsync(fd) != 0) { - error = "can't write " + recorder::shortPath(temp) + ": " + std::strerror(errno); + const std::string all = fresh ? headerText() + text : text; + if (!writeAll(fd, all.data(), all.size(), false) || ::fsync(fd) != 0) { + error = "can't write " + recorder::shortPath(path) + ": " + std::strerror(errno); ::close(fd); - ::unlink(temp.c_str()); return false; } ::close(fd); - if (std::rename(temp.c_str(), path.c_str()) != 0) { - error = "can't replace " + recorder::shortPath(path) + ": " + std::strerror(errno); - ::unlink(temp.c_str()); - return false; - } - return true; -} - -bool create(const std::string& home, const std::string& version, std::time_t now, std::string& dir, Info& info, - std::string& error) { - if (::mkdir(home.c_str(), 0755) != 0 && errno != EEXIST) { - error = "can't create " + recorder::shortPath(home) + ": " + std::strerror(errno); - return false; - } - // (a second one in the same second gets the next second's name) - std::string path; - for (int later = 0;; ++later) { - path = home + "/" + folderName(now + later); - if (::mkdir(path.c_str(), 0755) == 0) break; - if (errno != EEXIST || later >= 60) { - error = "can't create " + recorder::shortPath(path) + ": " + std::strerror(errno); - return false; - } - } - Info made; - made.appVersion = version; - made.consent = localTime(now, true); - for (int step = 0; step < kStepCount; ++step) made.steps[step].protocol = protocolOf(step); - if (!writeInfo(path, made, error)) { - ::rmdir(path.c_str()); - return false; - } - dir = path; - info = made; return true; } @@ -415,6 +434,14 @@ long long folderBytes(const std::string& path) { return total; } +std::vector missingFiles(const std::string& dir) { + std::vector missing; + for (const char* file : kRequiredFiles) { + if (!isRealFile(dir + "/" + file)) missing.push_back(file); + } + return missing; +} + bool takeRecording(const std::string& home, const std::string& dir, const std::string& sessionDir, bool aborted, std::string& name, std::string& error) { const std::string base = baseName(sessionDir); @@ -443,8 +470,28 @@ bool takeRecording(const std::string& home, const std::string& dir, const std::s return true; } -void RunWatch::begin(int step, const std::string& protocol, double sentUnix) { - step_ = step; +std::string newestCalibResult(const std::string& home) { + std::vector found = names(home); + for (auto name = found.rbegin(); name != found.rend(); ++name) { + if (name->compare(0, 6, "calib_") != 0 || !stampAt(*name, 6)) continue; + std::string text; + if (file_io::read(home + "/" + *name + "/calib_result.json", text, kMaxCalibBytes) && !text.empty()) { + return text; + } + } + return ""; +} + +std::string headsetOffBetween(const Recording& before, long long offCountStart, double eyecamStarted) { + if (before.offCountEnd < 0 || offCountStart < 0 || before.eyecamStarted <= 0 || eyecamStarted <= 0 || + std::fabs(before.eyecamStarted - eyecamStarted) > 1.0) { + return "unknown"; + } + return offCountStart > before.offCountEnd ? "yes" : "no"; +} + +void RunWatch::begin(const std::string& protocol, double sentUnix) { + active_ = true; protocol_ = protocol; sent_ = sentUnix; seen_ = false; @@ -454,7 +501,7 @@ void RunWatch::begin(int step, const std::string& protocol, double sentUnix) { RunEnd RunWatch::follow(const eyecam::Status& s, bool fresh, double nowUnix, double sessionMtime) { using eyecam::State; RunEnd end; - if (step_ < 0) return end; + if (!active_) return end; // eyecam-rec gone (or stalled) for long: nothing will come of it if (!fresh) { if (staleSince_ < 0) staleSince_ = nowUnix; @@ -471,7 +518,7 @@ RunEnd RunWatch::follow(const eyecam::Status& s, bool fresh, double nowUnix, dou return end; } if (s.state != State::Idle && s.state != State::Error) { - // (something else runs: a calibration, or another recording before ours started) + // (something else runs: a calibration, or another recording before this one started) if (!seen_ && nowUnix - sent_ >= kStartGraceSec) { end.outcome = Outcome::Failed; end.why = Why::NoStart; @@ -509,95 +556,53 @@ RunEnd RunWatch::follow(const eyecam::Status& s, bool fresh, double nowUnix, dou return end; } -void WearWatch::start(double now) { - *this = WearWatch(); - watching_ = true; - start_ = now; -} - -void WearWatch::follow(const eyecam::Status& s, bool fresh, double now) { - if (!watching_ || detected_) return; - // The loop stood still that long: the Frame slept (the headset off) - if (last_ >= 0 && now - last_ >= kOffSec) off_ = true; - last_ = now; - if (!fresh) { - proxOffSince_ = videoOffSince_ = onSince_ = -1.0; - return; - } - // The sensor: off only after it read worn (a face it never reaches the threshold on reads "off" all the time) - const bool proxKnown = std::isfinite(s.prox) && s.prox >= 0 && std::isfinite(s.proxMin); - const bool proxWorn = proxKnown && s.prox > s.proxMin; - proxWorn_ |= proxWorn; - if (proxKnown && !proxWorn && proxWorn_) { - if (proxOffSince_ < 0) proxOffSince_ = now; - if (now - proxOffSince_ >= kOffSec) off_ = true; - } else { - proxOffSince_ = -1.0; - } - // The eye video, while eyecam-rec looks at it (idle with live processing) - const bool videoKnown = s.live && s.state == eyecam::State::Idle; - if (videoKnown && !s.locked) { - if (videoOffSince_ < 0) videoOffSince_ = now; - if (now - videoOffSince_ >= kOffSec) off_ = true; - } else { - videoOffSince_ = -1.0; - } - // On again: the video back (or the sensor, without the video to go by) - const bool on = videoKnown ? s.locked : proxWorn; - if (off_ && on) { - if (onSince_ < 0) onSince_ = now; - if (now - onSince_ >= kOnSec) detected_ = true; - } else { - onSince_ = -1.0; - } -} - -Screen screen(const View& view, bool extra) { +Screen screen(const View& view, Page page, bool agreed) { if (view.progress.job == Job::Delete && view.progress.running) return Screen::Deleting; - if (view.dir.empty()) return Screen::Consent; - if (view.running >= 0) return Screen::Running; - const int step = nextStep(view.info, extra); - if (step >= kStepCount) return Screen::Done; - if (view.failure.step == step) return Screen::Failed; - return needsRewear(step) && view.info.nextWear.empty() ? Screen::Rewear : Screen::Ready; + if (page == Page::Manage) return Screen::Done; + if (view.running) return Screen::Running; + if (consentNeeded(view.info)) return agreed ? Screen::Eyewear : Screen::Consent; + if (view.failure.shown) return Screen::Failed; + return view.justDone ? Screen::Done : Screen::Ready; } -bool startAllowed(const View& view, const eyecam::View& eyecam, int step) { +bool startAllowed(const View& view, const eyecam::View& eyecam) { const eyecam::State state = eyecam.status.state; - return !view.dir.empty() && step >= 0 && step < kStepCount && eyecam::setUp(eyecam) && !eyecam.busy && - (state == eyecam::State::Idle || state == eyecam::State::Error) && view.running < 0 && - !view.progress.running && fits(step, view.freeBytes, view.fps); + return view.exists && !consentNeeded(view.info) && eyecam::setUp(eyecam) && !eyecam.busy && + (state == eyecam::State::Idle || state == eyecam::State::Error) && !view.running && + !view.progress.running && fits(view.freeBytes, view.fps); } -bool packEntries(const std::string& dir, const Info& info, std::vector& entries, std::string& error) { +bool packEntries(const std::string& dir, const Info& info, std::vector& entries, + std::vector& incomplete, std::string& error) { entries.clear(); - const std::string top = baseName(dir); + incomplete.clear(); + const std::string top = kFolderName; struct stat st {}; if (::stat(dir.c_str(), &st) != 0) { error = "can't read " + recorder::shortPath(dir) + ": " + std::strerror(errno); return false; } entries.push_back({dir, top + "/", 0, st.st_mtime, true}); - const std::string infoPath = dir + "/" + kInfoFile; - if (::stat(infoPath.c_str(), &st) != 0) { - error = "can't read " + recorder::shortPath(infoPath) + ": " + std::strerror(errno); + const auto file = [&](const std::string& name) { + const std::string path = dir + "/" + name; + if (::lstat(path.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) return false; + entries.push_back({path, top + "/" + name, static_cast(st.st_size), st.st_mtime, false}); + return true; + }; + if (!file(kInfoFile)) { + error = "can't read " + recorder::shortPath(dir + "/" + kInfoFile); return false; } - entries.push_back({infoPath, top + "/" + kInfoFile, static_cast(st.st_size), st.st_mtime, false}); - for (const Step& step : info.steps) { - if (step.rec.empty()) continue; - const std::string rec = dir + "/" + step.rec; - if (!isRealDir(rec) || ::stat(rec.c_str(), &st) != 0) { - error = "the recording " + step.rec + " isn't there"; - return false; - } - entries.push_back({rec, top + "/" + step.rec + "/", 0, st.st_mtime, true}); - for (const std::string& name : names(rec)) { - const std::string path = rec + "/" + name; - if (::lstat(path.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) continue; - entries.push_back({path, top + "/" + step.rec + "/" + name, static_cast(st.st_size), - st.st_mtime, false}); + for (const Recording& r : info.recordings) { + const std::string rec = dir + "/" + r.rec; + // (one lacking a file, or gone, is left out and named: it is to be recorded again) + if (!isRealDir(rec) || !missingFiles(rec).empty() || ::stat(rec.c_str(), &st) != 0) { + incomplete.push_back(r.rec); + continue; } + entries.push_back({rec, top + "/" + r.rec + "/", 0, st.st_mtime, true}); + for (const std::string& name : names(rec)) file(r.rec + "/" + name); + if (r.calib != "none" && !r.calib.empty()) file(r.calib); } for (const Entry& entry : entries) { if (entry.name.size() >= 100) { @@ -631,13 +636,13 @@ std::string tarHeader(const Entry& entry) { std::memset(header + 148, ' ', 8); unsigned int sum = 0; for (unsigned char c : header) sum += c; - std::snprintf(header + 148, 8, "%06o", sum); + putOctal(header + 148, 7, sum); header[155] = ' '; return std::string(header, sizeof(header)); } -std::string packName(const std::string& dir, bool compressed) { - return baseName(dir) + (compressed ? ".tar.zst" : ".tar"); +std::string packName(std::time_t when, bool compressed) { + return "contrib_" + recorder::fileStamp(when) + (compressed ? ".tar.zst" : ".tar"); } bool zstdAvailable() { @@ -663,37 +668,29 @@ void Worker::reap() { cancel_ = false; } -bool Worker::pack(const std::string& dir, const Info& info, bool compressed) { +bool Worker::pack(const std::string& dir, const std::vector& entries, const std::string& name, int count, + bool compressed) { if (progress().running) return false; reap(); - std::vector entries; - std::string error; Progress start; start.job = Job::Pack; - start.output = packName(dir, compressed); - if (!packEntries(dir, info, entries, error)) { - std::lock_guard lock(mutex_); - progress_ = start; - progress_.finished = true; - progress_.error = error; - reported_ = false; - return false; - } - for (const Entry& entry : entries) start.total += entry.size; start.running = true; + start.output = name; + start.count = count; + for (const Entry& entry : entries) start.total += entry.size; { std::lock_guard lock(mutex_); progress_ = start; reported_ = true; } - thread_ = std::thread([this, dir, entries, compressed]() { runPack(dir, entries, compressed); }); + thread_ = std::thread([this, dir, entries, name, compressed]() { runPack(dir, entries, name, compressed); }); return true; } bool Worker::remove(const std::string& home, const std::string& dir) { if (progress().running) return false; reap(); - if (parentOf(dir) != home || !isFolderName(baseName(dir)) || !isRealDir(dir)) return false; + if (dir != home + "/" + kFolderName || !isRealDir(dir)) return false; { std::lock_guard lock(mutex_); progress_ = Progress(); @@ -732,10 +729,10 @@ void Worker::finish(bool ok, const std::string& error) { reported_ = false; } -void Worker::runPack(const std::string& dir, const std::vector& entries, bool compressed) { +void Worker::runPack(const std::string& dir, const std::vector& entries, const std::string& name, + bool compressed) { // Below the panel's own loop and VR (Linux sets a thread's niceness on its own) ::setpriority(PRIO_PROCESS, static_cast(::syscall(SYS_gettid)), kNice); - const std::string name = packName(dir, compressed); const std::string final = dir + "/" + name; const std::string part = final + ".part"; const int out = ::open(part.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); @@ -757,7 +754,7 @@ void Worker::runPack(const std::string& dir, const std::vector& entries, finish(false, "can't start zstd: " + why); return; } - std::vector args = {"zstd", "-3", "-T2", "-q", "-c"}; + std::vector args = {"zstd", "-" + std::to_string(kZstdLevel), "-T2", "-q", "-c"}; std::vector argv; for (std::string& arg : args) argv.push_back(arg.data()); argv.push_back(nullptr); @@ -888,71 +885,74 @@ void Session::load(View& view, const std::string& home) { view = View(); view.loaded = true; view.home = home; + view.dir = home + "/" + kFolderName; view.progress = progress; view.deleted = deleted; view.compress = zstdAvailable(); view.freeBytes = freeBytes(home); + view.exists = isRealDir(view.dir); run_.end(); - wear_.stop(); command_.clear(); - view.dir = findCurrent(home); - if (view.dir.empty()) return; - view.deleted = false; - if (!readInfo(view.dir, view.info, view.error)) { - view.dir.clear(); - return; - } + if (!view.exists) return; + std::string text; + file_io::read(view.dir + "/" + kInfoFile, text, kMaxInfoBytes); + parseInfo(text, view.info); view.totalBytes = folderBytes(view.dir); - // A recording it had started (the panel restarted meanwhile): followed on from when it was sent - if (view.info.running >= 0) { - run_.begin(view.info.running, protocolOf(view.info.running), view.info.runningSince); - view.running = view.info.running; - } } -bool Session::consent(View& view, const std::string& version, std::time_t now) { - if (!view.dir.empty()) return false; - std::string dir; - Info info; - if (!create(view.home, version, now, dir, info, view.error)) return false; - view.dir = dir; - view.info = info; - view.error.clear(); - view.failure = Failure(); +bool Session::consent(View& view, Eyewear eyewear, std::time_t now) { + if (eyewear == Eyewear::Unasked || view.home.empty()) return false; + for (const std::string& dir : {view.home, view.dir}) { + if (::mkdir(dir.c_str(), 0755) != 0 && errno != EEXIST) { + view.error = "can't create " + recorder::shortPath(dir) + ": " + std::strerror(errno); + return false; + } + } + view.exists = isRealDir(view.dir); + if (!append(view, consentSection(localTime(now, true), kConsentVersion, eyewear))) return false; view.deleted = false; - view.progress = Progress(); + view.failure = Failure(); return true; } -bool Session::rewore(View& view) { - const int step = nextStep(view.info, true); - if (view.dir.empty() || view.running >= 0 || step >= kStepCount || !needsRewear(step) || - !view.info.nextWear.empty()) { - return false; - } - view.info.nextWear = kWearButton; - wear_.stop(); - save(view); - return true; +std::string Session::startCommand(const View& view, const eyecam::View& eyecam) const { + if (!startAllowed(view, eyecam) || !command_.empty()) return ""; + return std::string(eyecam::kStartCommand) + " " + kProtocol; } -std::string Session::startCommand(const View& view, const eyecam::View& eyecam, int step) const { - // Only the step whose turn it is (the optional one only after the others), with its re-wear done - if (step != nextStep(view.info, step >= kRequiredSteps) || !startAllowed(view, eyecam, step) || - !command_.empty() || (needsRewear(step) && view.info.nextWear.empty())) { - return ""; - } - return std::string(eyecam::kStartCommand) + " " + protocolOf(step); -} - -void Session::started(View& view, int step, const std::string& command, double sentUnix) { +void Session::started(View& view, const eyecam::View& eyecam, const Snapshot& snapshot, const std::string& command, + double sentUnix) { + const eyecam::Status& s = eyecam.status; command_ = command; - run_.begin(step, protocolOf(step), sentUnix); - view.running = step; + run_.begin(kProtocol, sentUnix); + view.running = true; + view.justDone = false; view.failure = Failure(); - view.info.running = step; - view.info.runningSince = sentUnix; - save(view); + // What its section says about the start: the settings, the time since the one before, the count of taking-offs + Recording r; + r.protocol = kProtocol; + r.startUnix = sentUnix; + r.start = localTime(static_cast(sentUnix), true); + r.appVersion = snapshot.appVersion; + r.eyecamRec = snapshot.eyecamRec; + r.lidVeto = s.hasLidVeto ? (s.lidVeto ? "on" : "off") : "unknown"; + r.widen = snapshot.widen ? "on" : "off"; + r.widenSensitivity = s.hasWidenSensitivity ? number(s.widenSensitivity, 2) : "unknown"; + r.cameraLids = snapshot.cameraLids ? "on" : "off"; + r.eyewear = eyewearName(view.info.eyewear); + r.fps = eyecam::cameraFps(s); + r.offCountStart = s.hasHeadsetOff ? s.headsetOffCount : -1; + r.eyecamStarted = s.hasHeadsetOff ? s.startedUnix : 0.0; + if (view.info.recordings.empty()) { + r.sincePrevious = "none"; + r.headsetOff = "none"; + } else { + const Recording& before = view.info.recordings.back(); + r.sincePrevious = before.endUnix > 0 ? number((sentUnix - before.endUnix) / 60.0, 1) : "unknown"; + r.headsetOff = headsetOffBetween(before, r.offCountStart, r.eyecamStarted); + } + pending_ = r; + calibText_ = newestCalibResult(view.home); } bool Session::replied(View& view, const eyecam::Reply& reply) { @@ -964,7 +964,7 @@ bool Session::replied(View& view, const eyecam::Reply& reply) { end.why = Why::Refused; end.message = reply.error; std::string log; - ended(view, end, log); + ended(view, end, eyecam::View(), static_cast(std::time(nullptr)), log); return true; } @@ -978,40 +978,19 @@ bool Session::follow(View& view, const eyecam::View& eyecam, double nowUnix, dou } const double fps = eyecam::cameraFps(eyecam.status); if (std::isfinite(fps) && fps > 0) view.fps = fps; - const bool fresh = eyecam::tabVisible(eyecam.status, nowUnix); - // The recording running + // The recording running (only once its start is answered) if (run_.active() && command_.empty()) { double mtime = NAN; struct stat st {}; if (!eyecam.status.sessionDir.empty() && ::stat(eyecam.status.sessionDir.c_str(), &st) == 0) { mtime = static_cast(st.st_mtime); } - const RunEnd end = run_.follow(eyecam.status, fresh, nowUnix, mtime); + const RunEnd end = run_.follow(eyecam.status, eyecam::tabVisible(eyecam.status, nowUnix), nowUnix, mtime); if (end.outcome != Outcome::Running) { - ended(view, end, log); - changed = true; - } - } - // The re-wear the next step waits for (the optional one's too, once the others are done) - const int next = nextStep(view.info, true); - if (!view.dir.empty() && view.running < 0 && next < kStepCount && needsRewear(next) && view.info.nextWear.empty()) { - if (!wear_.watching()) wear_.start(bootNow); - wear_.follow(eyecam.status, fresh, bootNow); - if (wear_.detected()) { - view.info.nextWear = kWearDetected; - wear_.stop(); - save(view); - log += "re-wear before step " + std::to_string(next + 1) + " seen\n"; + ended(view, end, eyecam, nowUnix, log); changed = true; } - } else { - wear_.stop(); } - const bool off = wear_.watching() && wear_.offSeen(); - const bool ready = wear_.fallbackReady(bootNow); - changed |= off != view.offSeen || ready != view.fallbackReady; - view.offSeen = off; - view.fallbackReady = ready; // The pack or the delete const Progress progress = worker_.progress(); changed |= progress.running != view.progress.running || progress.done != view.progress.done || @@ -1021,17 +1000,20 @@ bool Session::follow(View& view, const eyecam::View& eyecam, double nowUnix, dou if (worker_.takeFinished(finished)) { changed = true; if (finished.job == Job::Pack && finished.ok) { - view.info.packed = finished.output; - view.info.packedBytes = finished.outputBytes; + // (the pack before it holds fewer: it goes; the recordings stay) + const std::string before = view.info.packed; + if (append(view, packedSection(finished.output, finished.count, finished.outputBytes)) && + isPackName(before) && before != finished.output) { + ::unlink((view.dir + "/" + before).c_str()); + } view.totalBytes = folderBytes(view.dir); - save(view); - log += "packed into " + finished.output + " (" + sizeText(finished.outputBytes) + ")\n"; + log += "packed " + std::to_string(finished.count) + " recording(s) into " + finished.output + " (" + + sizeText(finished.outputBytes) + ")\n"; } else if (finished.job == Job::Delete && finished.ok) { log += "deleted " + recorder::shortPath(view.dir) + "\n"; const std::string home = view.home; view.deleted = true; load(view, home); - view.deleted = view.dir.empty(); } else { log += std::string(finished.job == Job::Pack ? "pack" : "delete") + " failed: " + finished.error + "\n"; } @@ -1039,80 +1021,98 @@ bool Session::follow(View& view, const eyecam::View& eyecam, double nowUnix, dou return changed; } -bool Session::pack(View& view) { - if (view.dir.empty() || view.running >= 0 || requiredDone(view.info) < kRequiredSteps) return false; - const bool ok = worker_.pack(view.dir, view.info, view.compress); +bool Session::pack(View& view, std::time_t now) { + if (!view.exists || view.running || view.info.recordings.empty()) return false; + std::vector entries; + std::string error; + if (!packEntries(view.dir, view.info, entries, view.incomplete, error)) { + view.progress = Progress(); + view.progress.job = Job::Pack; + view.progress.finished = true; + view.progress.error = error; + return false; + } + const int count = static_cast(view.info.recordings.size() - view.incomplete.size()); + if (count == 0) return false; + const bool ok = worker_.pack(view.dir, entries, packName(now, view.compress), count, view.compress); view.progress = worker_.progress(); return ok; } bool Session::remove(View& view) { - if (view.dir.empty() || view.running >= 0) return false; + if (!view.exists || view.running) return false; if (!worker_.remove(view.home, view.dir)) return false; - run_.end(); - wear_.stop(); view.progress = worker_.progress(); return true; } -bool Session::save(View& view) { +bool Session::append(View& view, const std::string& text) { std::string error; - if (writeInfo(view.dir, view.info, error)) return true; - view.error = error; - return false; + if (!appendInfo(view.dir, text, error)) { + view.error = error; + return false; + } + std::string all; + file_io::read(view.dir + "/" + kInfoFile, all, kMaxInfoBytes); + parseInfo(all, view.info); + view.error.clear(); + return true; } -void Session::ended(View& view, const RunEnd& end, std::string& log) { - const int step = run_.step() >= 0 ? run_.step() : view.running; +void Session::ended(View& view, const RunEnd& end, const eyecam::View& eyecam, double nowUnix, std::string& log) { run_.end(); command_.clear(); - view.running = -1; - view.info.running = -1; - view.info.runningSince = 0; - if (step < 0 || step >= kStepCount) return; - const std::string label = "step " + std::to_string(step + 1); - if (end.outcome == Outcome::Done) { + view.running = false; + // Done, and with every file it needs: into the folder, with its section and its calibration result + std::vector missing; + if (end.outcome == Outcome::Done) missing = missingFiles(end.dir); + if (end.outcome == Outcome::Done && missing.empty()) { std::string name; std::string error; if (takeRecording(view.home, view.dir, end.dir, false, name, error)) { - Step& done = view.info.steps[step]; - done.protocol = protocolOf(step); - done.rec = name; - done.bytes = folderBytes(view.dir + "/" + name); - done.wear = needsRewear(step) ? view.info.nextWear : std::string(kWearFirst); - done.done = localTime(std::time(nullptr), false); - view.info.nextWear.clear(); - view.failure = Failure(); - log += label + " recorded: " + name + " (" + sizeText(done.bytes) + ")\n"; + Recording r = pending_; + r.rec = name; + r.endUnix = nowUnix; + r.bytes = folderBytes(view.dir + "/" + name); + const eyecam::Status& s = eyecam.status; + r.offCountEnd = s.hasHeadsetOff && std::fabs(s.startedUnix - r.eyecamStarted) <= 1.0 ? s.headsetOffCount + : -1; + r.calib = "none"; + if (!calibText_.empty() && writeFile(view.dir + "/" + name + ".calib_result.json", calibText_)) { + r.calib = name + ".calib_result.json"; + } + append(view, recordingSection(r)); view.totalBytes = folderBytes(view.dir); - save(view); + view.justDone = true; + log += "recorded " + name + " (" + sizeText(r.bytes) + "; headset off since the one before: " + + r.headsetOff + ")\n"; return; } - view.failure = {step, Why::Move, error, error, ""}; - view.info.aborted.push_back(recorder::shortPath(end.dir) + " not moved: " + error); - log += label + " recorded, but " + error + "\n"; - save(view); + view.failure = {true, Why::Move, error, error, ""}; + log += "recorded, but " + error + "\n"; return; } - // Didn't finish: its part (if any) goes to aborted/, and the step can be tried again - view.failure = {step, end.why, end.message, end.messageEn, ""}; - std::string kept; + // Didn't finish, or lacks a file: its part (if any) goes to aborted/, and it can be recorded again + view.failure = {true, end.why, end.message, end.messageEn, ""}; + std::string why = end.outcome == Outcome::Stopped ? "stopped" : "failed"; + if (!missing.empty()) { + std::string list; + for (const std::string& file : missing) list += (list.empty() ? "" : ", ") + file; + view.failure = {true, Why::Incomplete, list, list, ""}; + why = "missing " + list; + } if (!end.dir.empty()) { std::string name; std::string error; if (takeRecording(view.home, view.dir, end.dir, true, name, error)) { - kept = name; view.failure.kept = name; + append(view, abortedSection(name, why)); } else { - kept = recorder::shortPath(end.dir) + " (" + error + ")"; + why += " (" + error + ")"; } } - const char* how = end.outcome == Outcome::Stopped ? "stopped" : "failed"; - if (!kept.empty()) view.info.aborted.push_back(kept + " " + how); - log += label + " " + how + (end.message.empty() ? "" : ": " + end.message) + - (kept.empty() ? "" : "; kept " + kept) + "\n"; view.totalBytes = folderBytes(view.dir); - save(view); + log += "the recording " + why + (end.message.empty() ? "" : ": " + end.message) + "\n"; } std::string sizeText(long long bytes) { diff --git a/panel/src/contrib.h b/panel/src/contrib.h index 2bca001..75ed088 100644 --- a/panel/src/contrib.h +++ b/panel/src/contrib.h @@ -1,16 +1,19 @@ -// "Help train the AI" (Advanced tab, Debug tools): eye-camera recordings a user makes for the developer, to tune and -// train eyecam's half-shut check (the eyelid veto's CNN). Three recordings of eyecam's protocol without light, each in -// a wear of its own (the headset taken off and put back on before the next one), then one of free use; a second free -// use after them is optional. eyecam-rec records each into ~/eyecam/rec_/ as always (with its cues.csv, -// valve.csv, frames.csv and meta.txt); once it has ended the panel moves the folder into the contribution's folder -// ~/eyecam/contrib_/, whose info.txt keeps the progress (so it goes on after the panel or the Frame restarts) and -// says what is in it for the developer (no personal data). The folder can be packed into one file and deleted from the -// panel. Everything here works without OpenVR and cairo (contrib-test). +// "Record for squint training" (Advanced tab, Debug tools): one press records eyecam's protocol for the training data +// of its half-shut check (the eyelid veto's CNN) and keeps it for the user to send to the developer. The first time +// (and whenever the consent's text changes) it asks for the consent and whether glasses or prescription inserts are +// worn; then one recording of protocol_contrib.txt (about 93 s, no full-view light: closes, squints, widening, looking +// up, down, left and right). eyecam-rec records it into ~/eyecam/rec_/ as always (with its cues.csv, valve.csv, +// frames.csv and meta.txt); once it has ended, and has those four, the panel moves it into ~/eyecam/contrib/, which +// gathers every recording made this way, and adds a section about it to that folder's info.txt (the settings then, +// the minutes since the one before and whether the headset was taken off in between, from eyecam-rec's count; no +// personal data). The folder can be packed into one file and deleted from the panel. Everything here works without +// OpenVR and cairo (contrib-test). #pragma once #include "eyecam.h" #include +#include #include #include #include @@ -19,168 +22,174 @@ namespace contrib { -/** The guided recordings' protocol (eyecam's protocol_widen_nolight.txt: no full-view light). */ -constexpr const char* kGuidedProtocol = "widen_nolight"; -/** The free use's (protocol_free.txt: 180 s of "normal", no prompts). */ -constexpr const char* kFreeProtocol = "free"; -/** How long the guided protocol's steps take (s; the lead-in included). */ -constexpr double kGuidedSec = 59.0; -/** ...and the free use's. */ -constexpr double kFreeSec = 180.0; +/** The protocol recorded (eyecam's protocol_contrib.txt). */ +constexpr const char* kProtocol = "contrib"; +/** How long its steps take (s; the lead-in included). */ +constexpr double kProtocolSec = 93.0; /** eyecam-rec records this much past a protocol's last step (s), for the end beep. */ constexpr double kTailSec = 1.5; -/** The steps everyone is asked for: three guided, one free use. */ -constexpr int kRequiredSteps = 4; -/** ...and the optional second free use after them. */ -constexpr int kStepCount = 5; +/** The consent's text version: a newer one asks for the consent again. */ +constexpr int kConsentVersion = 1; +/** The folder in ~/eyecam that gathers the recordings. */ +constexpr const char* kFolderName = "contrib"; +/** Its file about them (a section added for each event). */ +constexpr const char* kInfoFile = "info.txt"; +/** Where a recording that didn't finish (or lacks a file) goes in it: never packed, deleted with it. */ +constexpr const char* kAbortedDir = "aborted"; +/** The files every recording needs for training (the labels come from cues.csv and Valve's openness). */ +constexpr const char* kRequiredFiles[] = {"frames.csv", "valve.csv", "cues.csv", "meta.txt"}; /** What eyecam-rec writes per frame and eye (a 400x400 grey frame, its header, its rows), as eyecam-rec estimates. */ constexpr double kFrameBytes = 160300.0; -/** The eye cameras' usual rate, for the estimates while eyecam-rec doesn't say. */ +/** The eye cameras' usual rate, for the estimate while eyecam-rec doesn't say. */ constexpr double kDefaultFps = 90.0; -/** Space left free after everything (eyecam-rec stops a recording with less than 1 GiB left). */ +/** Space left free after a recording (eyecam-rec stops one with less than 1 GiB left), and after a pack. */ constexpr long long kMarginBytes = 1LL << 30; -/** The packed file is at most this share of the recordings (zstd -3 makes the eye video about 0.62 of its size). */ -constexpr double kPackShare = 0.75; -/** The headset counts as taken off once the eye video stopped (or the sensor read off) this long (s)... */ -constexpr double kOffSec = 3.0; -/** ...and as put back on once the video (or the sensor) was back this long after that (s). */ -constexpr double kOnSec = 2.0; -/** "I put it back on" can be pressed this long after a step began waiting for the re-wear (s). */ -constexpr double kFallbackSec = 20.0; +/** zstd's level for the pack: on the Frame's eye video -3 gave 0.62 of the size in 7.7 s for 2 GB, -6 0.58 in 20 s, + * -9 0.56 in 43 s, -12 0.55 in 90 s (zip -6: 0.60 in 166 s, gzip -1: 0.62 in 19 s). */ +constexpr int kZstdLevel = 6; +/** The pack is at most this share of the recordings (0.58 measured at kZstdLevel). */ +constexpr double kPackShare = 0.7; /** A start that eyecam-rec doesn't show running within this long (s) didn't start. */ constexpr double kStartGraceSec = 8.0; /** eyecam-rec silent (its status not fresh) this long (s) ends a recording as failed. */ constexpr double kGoneSec = 10.0; -/** The contribution's file in its folder. */ -constexpr const char* kInfoFile = "info.txt"; -/** Where tries that didn't finish go in it (not packed; deleted with it). */ -constexpr const char* kAbortedDir = "aborted"; -/** How the re-wear before a step was known (info.txt). */ -constexpr const char* kWearFirst = "first"; ///< the first step: the wear the user consented in -constexpr const char* kWearDetected = "detected"; ///< seen: off (the eye video or the sensor), then on again -constexpr const char* kWearButton = "button"; ///< "I put it back on" pressed - -/** One step as info.txt keeps it. */ -struct Step { - std::string protocol; ///< the protocol recorded ("" = not done) - std::string rec; ///< its recording's folder in the contribution's folder ("" = not done) - long long bytes = 0; ///< its size - std::string wear; ///< how the re-wear before it was known (kWear...) - std::string done; ///< when it ended (local "YYYY-MM-DD HH:MM:SS") -}; - -/** info.txt: the contribution's progress, and what is in it for the developer. */ -struct Info { - std::string appVersion; ///< the panel's version at the consent - std::string consent; ///< when the user agreed (local "YYYY-MM-DD HH:MM:SS +zzzz") - Step steps[kStepCount]; - std::string nextWear; ///< how the re-wear before the next step was known ("" = not yet; kWear...) - int running = -1; ///< the step whose recording was started (-1 = none) - double runningSince = 0; ///< ...when its start was sent (Unix seconds) - std::vector aborted; ///< tries that didn't finish ("rec_... stopped"), kept in aborted/ - std::string packed; ///< the one file packed from it, in its folder ("" = none) - long long packedBytes = 0; -}; +/** Glasses or prescription inserts in the headset (asked with the consent; the training looks at them apart). */ +enum class Eyewear { Unasked, None, Glasses, Inserts, NoAnswer }; /** - * The protocol of a step. - * @param step 0 .. kStepCount - 1 - * @return kGuidedProtocol for the first three, kFreeProtocol after them + * The name info.txt writes. + * @param eyewear the answer + * @return "none", "glasses", "inserts", "no_answer" ("" for Unasked) */ -const char* protocolOf(int step); +const char* eyewearName(Eyewear eyewear); /** - * Whether a step is free use. - * @param step the step - * @return true for 3 and 4 - */ -bool isFree(int step); + * Read a name info.txt wrote. + * @param name the name + * @return the answer (Unasked for anything else) + */ +Eyewear parseEyewear(const std::string& name); + +/** One recording's section in info.txt. */ +struct Recording { + std::string rec; ///< its folder in the contribution's ("rec_YYYY-MM-DD_HH-MM-SS") + std::string protocol; ///< the protocol recorded + std::string start; ///< when its start was sent (local "YYYY-MM-DD HH:MM:SS +zzzz") + double startUnix = 0.0; ///< ...in Unix seconds + double endUnix = 0.0; ///< when the panel saw it ended + long long bytes = 0; ///< its folder's size + std::string appVersion; ///< frameeyeosc's version + std::string eyecamRec; ///< the first 8 hex of eyecam-rec's SHA-256 ("" = unknown; it has no git hash) + std::string lidVeto; ///< eyecam's eyelid veto: "on", "off", "unknown" (an eyecam-rec before it) + std::string widen; ///< frameeyeosc's widening (`widen`): "on" / "off" + std::string widenSensitivity; ///< eyecam's ("0.50"; "unknown") + std::string cameraLids; ///< the eye cameras switched on for the eyelids (`camera_lids`): "on" / "off" + std::string eyewear; ///< eyewearName at the time + double fps = NAN; ///< the eye cameras' frames a second at the start (NaN = unknown) + std::string sincePrevious; ///< minutes since the previous recording ended ("12.5"; "none" for the first) + std::string headsetOff; ///< the headset taken off since then: "yes", "no", "unknown", "none" (the first) + long long offCountStart = -1; ///< eyecam-rec's headset_off_count at the start (-1 = unknown) + long long offCountEnd = -1; ///< ...and at the end + double eyecamStarted = 0.0; ///< eyecam-rec's started_unix (the count is per run; 0 = unknown) + std::string calib; ///< the calibration's result copied beside it (".calib_result.json"; "none") +}; + +/** info.txt as read: the consent and every recording. */ +struct Info { + int consentVersion = 0; ///< the consent's text version agreed to (0 = none) + std::string consent; ///< when (local time) + Eyewear eyewear = Eyewear::Unasked; + std::vector recordings; + std::vector aborted; ///< tries that didn't finish or lacked a file ("aborted/rec_... why") + std::string packed; ///< the last pack's file in the folder ("" = none) + int packedCount = 0; ///< ...how many recordings it holds + long long packedBytes = 0; +}; /** - * Whether a step waits for the headset to be put on again first: all but the first. - * @param step the step - * @return true for 1 and on + * Whether the consent is to be asked (again): none, an older text's, or no answer about glasses yet. + * @param info the file as read + * @return true to ask */ -bool needsRewear(int step); +bool consentNeeded(const Info& info); /** - * What a step's recording takes on the disk (eyecam-rec's own estimate: both eyes, every frame). - * @param step the step + * What a recording takes on the disk (eyecam-rec's own estimate: both eyes, every frame). * @param fps the eye cameras' frame rate (kDefaultFps when not a number above 0) * @return bytes */ -long long estimateBytes(int step, double fps); +long long estimateBytes(double fps); /** - * The step to do next: the first of the required ones not done; once they are, the optional one when it is asked for - * (extra) and not done. - * @param info the progress - * @param extra the optional free use was asked for - * @return the step, or kStepCount when there is none + * Whether a recording fits: its estimate and kMarginBytes are free (or the free space isn't known). + * @param freeBytes the free space (-1 = unknown) + * @param fps the eye cameras' frame rate + * @return true if it fits */ -int nextStep(const Info& info, bool extra); +bool fits(long long freeBytes, double fps); /** - * How many of the required steps are done. - * @param info the progress - * @return 0 .. kRequiredSteps + * The recordings' size. + * @param info the file as read + * @return bytes */ -int requiredDone(const Info& info); +long long recordedBytes(const Info& info); /** - * The recordings' size so far. - * @param info the progress + * What packing needs free: kPackShare of the recordings (all of them without zstd) and kMarginBytes. + * @param info the file as read + * @param compressed packed with zstd * @return bytes */ -long long recordedBytes(const Info& info); +long long packNeedBytes(const Info& info, bool compressed); /** - * The space still needed for the required steps not done, with kMarginBytes. - * @param info the progress - * @param fps the eye cameras' frame rate - * @return bytes + * The top of a new info.txt: what it is, and its format. + * @return the text */ -long long neededBytes(const Info& info, double fps); +std::string headerText(); /** - * Whether a step's recording fits: its estimate and kMarginBytes are free (or the free space isn't known). - * @param step the step - * @param freeBytes the free space (-1 = unknown) - * @param fps the eye cameras' frame rate - * @return true if it fits + * A consent's section. + * @param time when (local) + * @param version the text's version + * @param eyewear the answer about glasses + * @return the text */ -bool fits(int step, long long freeBytes, double fps); +std::string consentSection(const std::string& time, int version, Eyewear eyewear); /** - * What packing needs free: kPackShare of the recordings (all of them without zstd) and kMarginBytes. - * @param info the progress - * @param compressed packed with zstd - * @return bytes + * A recording's section. + * @param r the recording + * @return the text */ -long long packNeedBytes(const Info& info, bool compressed); +std::string recordingSection(const Recording& r); /** - * info.txt's text. - * @param info the progress - * @return the text (a comment, then "key=value" lines) + * A try that didn't finish (or lacked a file). + * @param name where it went ("aborted/rec_...") + * @param why why (English) + * @return the text */ -std::string formatInfo(const Info& info); +std::string abortedSection(const std::string& name, const std::string& why); /** - * Read info.txt's text: "key=value" lines, "#" comments; unknown keys and odd lines are skipped. - * @param text the text - * @param info filled in - * @return false if it has no consent (not a contribution's) + * A pack. + * @param file its name + * @param count the recordings in it + * @param bytes its size + * @return the text */ -bool parseInfo(const std::string& text, Info& info); +std::string packedSection(const std::string& file, int count, long long bytes); /** - * Whether a name is a contribution's folder: "contrib_YYYY-MM-DD_HH-MM-SS". - * @param name the name - * @return true if it is + * Read info.txt: "[section]" lines, "key=value" lines after them, "#" comments; unknown keys and odd lines skipped. + * The last consent counts. + * @param text the text + * @param info filled in */ -bool isFolderName(const std::string& name); +void parseInfo(const std::string& text, Info& info); /** * Whether a name is eyecam-rec's recording folder: "rec_YYYY-MM-DD_HH-MM-SS". @@ -190,11 +199,11 @@ bool isFolderName(const std::string& name); bool isRecordingName(const std::string& name); /** - * A contribution's folder name for a time. - * @param when the time - * @return "contrib_YYYY-MM-DD_HH-MM-SS" (local) + * Whether a name is a pack's: "contrib_YYYY-MM-DD_HH-MM-SS.tar.zst" (or ".tar"). + * @param name the name + * @return true if it is */ -std::string folderName(std::time_t when); +bool isPackName(const std::string& name); /** * A local time as info.txt writes it. @@ -205,51 +214,22 @@ std::string folderName(std::time_t when); std::string localTime(std::time_t when, bool zone); /** - * Where eyecam-rec records ($HOME/eyecam), and where the contributions go. + * Where eyecam-rec records ($HOME/eyecam); the contribution's folder is kFolderName in it. * @return the folder */ std::string defaultHome(); /** - * The contribution to show: the newest folder in home with a contribution's name and an info.txt that reads. - * @param home the folder - * @return its path, or "" for none - */ -std::string findCurrent(const std::string& home); - -/** - * Read a contribution's info.txt. - * @param dir its folder - * @param info filled in - * @param error why not - * @return true if read - */ -bool readInfo(const std::string& dir, Info& info, std::string& error); - -/** - * Write a contribution's info.txt (a temporary file, then renamed over it). - * @param dir its folder - * @param info the progress + * Add text to the end of the folder's info.txt (made with headerText first), on the disk before it returns. + * @param dir the folder + * @param text the text * @param error why not * @return true if written */ -bool writeInfo(const std::string& dir, const Info& info, std::string& error); - -/** - * Start a contribution: its folder in home, and info.txt with the consent. - * @param home where it goes - * @param version the panel's version - * @param now the time of the consent - * @param dir its folder - * @param info its progress - * @param error why not - * @return true if made - */ -bool create(const std::string& home, const std::string& version, std::time_t now, std::string& dir, Info& info, - std::string& error); +bool appendInfo(const std::string& dir, const std::string& text, std::string& error); /** - * The free space where a folder is (for the user: what an unprivileged user can fill). + * The free space where a folder is (what an unprivileged user can fill). * @param path the folder * @return bytes, or -1 if it can't be read */ @@ -263,8 +243,15 @@ long long freeBytes(const std::string& path); long long folderBytes(const std::string& path); /** - * Move a recording eyecam-rec has finished into the contribution's folder (aborted/ for one that didn't finish). Only - * one of eyecam-rec's own folders, right in home ("rec_"), is moved. + * The files a recording lacks (kRequiredFiles). + * @param dir its folder + * @return their names (empty: all there) + */ +std::vector missingFiles(const std::string& dir); + +/** + * Move a recording eyecam-rec has finished into the contribution's folder (aborted/ for one that didn't finish or + * lacks a file). Only one of eyecam-rec's own folders, right in home ("rec_"), is moved. * @param home eyecam-rec's folder * @param dir the contribution's folder * @param sessionDir the recording's folder (status.json's session_dir) @@ -276,7 +263,24 @@ long long folderBytes(const std::string& path); bool takeRecording(const std::string& home, const std::string& dir, const std::string& sessionDir, bool aborted, std::string& name, std::string& error); -/** How a recording of a step ended. */ +/** + * The newest calibration result eyecam keeps in home (calib_/calib_result.json, by name: they are dated). + * @param home eyecam-rec's folder + * @return its text ("" = none) + */ +std::string newestCalibResult(const std::string& home); + +/** + * Whether the headset was taken off between two recordings, from eyecam-rec's count: only within one run of it + * (the same started_unix), with both counts known. + * @param before the earlier recording + * @param offCountStart the count at the later one's start (-1 = unknown) + * @param eyecamStarted eyecam-rec's started_unix then (0 = unknown) + * @return "yes", "no" or "unknown" + */ +std::string headsetOffBetween(const Recording& before, long long offCountStart, double eyecamStarted); + +/** How a recording ended. */ enum class Outcome { Running, ///< not yet Done, ///< it ran its full time @@ -285,7 +289,7 @@ enum class Outcome { }; /** Why a try didn't finish (the screen says it). */ -enum class Why { None, Stopped, Error, Refused, Gone, NoStart, Missing, Move }; +enum class Why { None, Stopped, Error, Refused, Gone, NoStart, Missing, Move, Incomplete }; /** A recording's end. */ struct RunEnd { @@ -297,34 +301,29 @@ struct RunEnd { }; /** - * Follows a step's recording from the moment its start went out until eyecam-rec has ended it, from status.json alone - * (also after the panel restarted in the middle of it: began again with the time the start was sent). + * Follows a recording from the moment its start went out until eyecam-rec has ended it, from status.json alone. */ class RunWatch { public: /** * A start was sent. - * @param step the step * @param protocol its protocol * @param sentUnix when (Unix seconds) */ - void begin(int step, const std::string& protocol, double sentUnix); + void begin(const std::string& protocol, double sentUnix); /** It ended (or was given up). */ - void end() { step_ = -1; } + void end() { active_ = false; } /** @return true while following one */ - bool active() const { return step_ >= 0; } - - /** @return the step followed (-1 = none) */ - int step() const { return step_; } + bool active() const { return active_; } /** * Follow it. It runs while eyecam-rec searches or records this protocol; it ended once eyecam-rec is idle or in - * error again, after showing it running, or with a recording folder made after the start (a panel that restarted - * meanwhile). Idle with the folder: Done, or Stopped when eyecam-rec says it ended early (last_session_aborted); - * idle without one: Stopped (stopped while it searched). In error: Failed. Not running kStartGraceSec after the - * start, or eyecam-rec silent kGoneSec: Failed. + * error again after showing it running, or with a recording folder made after the start. Idle with the folder: + * Done, or Stopped when eyecam-rec says it ended early (last_session_aborted); idle without one: Stopped + * (stopped while it searched). In error: Failed. Not running kStartGraceSec after the start, or eyecam-rec silent + * kGoneSec: Failed. * @param s eyecam-rec's status * @param fresh it is fresh (eyecam::tabVisible) * @param nowUnix Unix seconds @@ -334,72 +333,18 @@ class RunWatch { RunEnd follow(const eyecam::Status& s, bool fresh, double nowUnix, double sessionMtime); private: - int step_ = -1; + bool active_ = false; std::string protocol_; double sent_ = 0.0; bool seen_ = false; ///< eyecam-rec was seen running it double staleSince_ = -1.0; ///< since when its status isn't fresh (-1 = it is) }; -/** - * Watches for the headset being taken off and put back on before a step, from eyecam-rec's status: off once the eye - * video stopped for kOffSec (idle, with its live processing on, which keeps looking at it), the proximity sensor read - * off for kOffSec after it read worn (some faces never reach its threshold: then only the video counts), or the - * Frame slept for kOffSec (the panel's loop stood still: taking the headset off lets it sleep); then on again once - * the video (without live processing, the sensor) is back for kOnSec. - */ -class WearWatch { -public: - /** - * Start watching (a step began waiting for the re-wear). - * @param now seconds on a clock that runs through suspend - */ - void start(double now); - - /** Stop watching. */ - void stop() { watching_ = false; } - - /** - * Follow eyecam-rec (each time its status is read). - * @param s its status - * @param fresh it is fresh - * @param now seconds on a clock that runs through suspend - */ - void follow(const eyecam::Status& s, bool fresh, double now); - - /** @return true while watching */ - bool watching() const { return watching_; } - - /** @return true once the headset was seen off */ - bool offSeen() const { return off_; } - - /** @return true once it was seen off and then on again */ - bool detected() const { return detected_; } - - /** - * Whether "I put it back on" can be pressed: kFallbackSec after the watch started. - * @param now seconds on the same clock - * @return true once it can - */ - bool fallbackReady(double now) const { return watching_ && now - start_ >= kFallbackSec; } - -private: - bool watching_ = false; - double start_ = 0.0; - double last_ = -1.0; ///< the last follow (-1 = none yet) - bool proxWorn_ = false; ///< the sensor read worn since the watch started - double proxOffSince_ = -1.0; - double videoOffSince_ = -1.0; - double onSince_ = -1.0; - bool off_ = false; - bool detected_ = false; -}; - /** What a try that didn't finish left for the screen. */ struct Failure { - int step = -1; ///< the step (-1 = none) + bool shown = false; Why why = Why::None; - std::string message; ///< eyecam-rec's (Japanese) or the reason + std::string message; ///< eyecam-rec's (Japanese), the reason, or the missing files std::string messageEn; std::string kept; ///< where its part went ("aborted/rec_..."; "" = none) }; @@ -418,75 +363,85 @@ struct Progress { std::string error; ///< why it failed (English) std::string output; ///< the packed file's name in the folder long long outputBytes = 0; + int count = 0; ///< the recordings packed }; /** The contribution as the panel shows it (PanelModel::contrib; the loop fills it in). */ struct View { bool loaded = false; ///< looked for since the panel started std::string home; ///< eyecam-rec's folder (defaultHome) - std::string dir; ///< the contribution's folder ("" = none: the first screen) - Info info; - long long totalBytes = 0; ///< everything in its folder (the recordings, aborted/, a pack), as last counted + std::string dir; ///< the contribution's folder (home/kFolderName) + bool exists = false; ///< ...is there + Info info; ///< its info.txt as read + long long totalBytes = 0; ///< everything in it (recordings, aborted/, a pack), as last counted std::string error; ///< info.txt couldn't be written, a folder couldn't be made (English; "" = none) long long freeBytes = -1; ///< free space there (-1 = unknown) - double fps = kDefaultFps; ///< the eye cameras' rate for the estimates - int running = -1; ///< the step recording now (-1 = none) - bool offSeen = false; ///< the step waiting for its re-wear saw the headset off - bool fallbackReady = false; ///< ..."I put it back on" can be pressed - Failure failure; ///< the last try that didn't finish + double fps = kDefaultFps; ///< the eye cameras' rate for the estimate + bool running = false; ///< a recording of it runs + bool justDone = false; ///< one has just been recorded (the end's screen, until the sheet is opened again) + Failure failure; ///< the last try, when it didn't finish + std::vector incomplete; ///< recordings the last pack left out (a file missing) Progress progress; ///< packing or deleting bool compress = true; ///< zstd is there (the pack is .tar.zst; else .tar) - bool deleted = false; ///< a contribution was just deleted (the first screen says so) + bool deleted = false; ///< it was just deleted (the first screen says so) +}; + +/** Which way the sheet was opened. */ +enum class Page { + Record, ///< "Record": the consent if needed, then the recording and its end + Manage, ///< "Recordings": the end's screen (how many, the folder, pack, delete) }; -/** What the contribution's sheet shows. */ +/** What the sheet shows. */ enum class Screen { - Consent, ///< what is recorded and how it is used, and "I'll help" - Rewear, ///< take the headset off and put it back on - Ready, ///< the next step's start - Running, ///< its recording (the eye capture's run) - Failed, ///< the last try didn't finish: why, and again - Done, ///< all recorded: pack, send, delete + Consent, ///< what is recorded and how it is used, and "Agree" + Eyewear, ///< glasses or inserts? + Ready, ///< what to do, and "Start" + Running, ///< the recording (the eye capture's run) + Failed, ///< it didn't finish: why, and again + Done, ///< recorded: how many, the folder, how to send, pack, delete Deleting, ///< being deleted }; /** * The sheet's screen. * @param view the contribution - * @param extra the optional free use was asked for + * @param page how it was opened + * @param agreed "Agree" was pressed (the question about glasses follows) * @return the screen */ -Screen screen(const View& view, bool extra); +Screen screen(const View& view, Page page, bool agreed); /** - * Whether a step's start can be pressed: eyecam-rec there and idle (or in error), set up, no command waiting, nothing - * running or being packed, and the step's recording fits. + * Whether "Start" can be pressed: the consent given, eyecam-rec there and idle (or in error), set up, no command + * waiting, nothing running or being packed, and the recording fits. * @param view the contribution * @param eyecam eyecam-rec - * @param step the step * @return true if it can */ -bool startAllowed(const View& view, const eyecam::View& eyecam, int step); +bool startAllowed(const View& view, const eyecam::View& eyecam); /** One file or folder in the pack. */ struct Entry { std::string path; ///< on the disk - std::string name; ///< in the archive ("contrib_.../rec_.../eye_L.raw"; folders end in "/") + std::string name; ///< in the archive ("contrib/rec_.../eye_L.raw"; folders end in "/") long long size = 0; ///< bytes (0 for a folder) std::time_t mtime = 0; bool folder = false; }; /** - * What a contribution's pack holds: its folder, info.txt, and each finished step's recording (its files sorted by - * name). Not aborted/ or an earlier pack. + * What the pack holds: the folder, info.txt, and each recording with all of kRequiredFiles (its files sorted by name) + * with its calibration result. Not aborted/ or a pack; a recording lacking a file is left out and named. * @param dir the contribution's folder - * @param info its progress + * @param info its info.txt as read * @param entries filled in - * @param error why not (a recording missing, a name too long) - * @return true if all are there + * @param incomplete the recordings left out + * @param error why not (a name too long, the folder unreadable) + * @return true if it can be packed */ -bool packEntries(const std::string& dir, const Info& info, std::vector& entries, std::string& error); +bool packEntries(const std::string& dir, const Info& info, std::vector& entries, + std::vector& incomplete, std::string& error); /** * A tar (ustar) header for an entry: sizes of 8 GiB and more in GNU's base-256. @@ -496,12 +451,12 @@ bool packEntries(const std::string& dir, const Info& info, std::vector& e std::string tarHeader(const Entry& entry); /** - * The pack's file name. - * @param dir the contribution's folder + * A pack's file name. + * @param when when it is made * @param compressed with zstd - * @return "contrib_.tar.zst" (or ".tar") + * @return "contrib_YYYY-MM-DD_HH-MM-SS.tar.zst" (or ".tar") */ -std::string packName(const std::string& dir, bool compressed); +std::string packName(std::time_t when, bool compressed); /** * Whether zstd can be run (in PATH). @@ -510,9 +465,9 @@ std::string packName(const std::string& dir, bool compressed); bool zstdAvailable(); /** - * Packs or deletes a contribution on a thread of its own, at a low priority, so the panel's loop never waits for it. - * The pack is written beside the recordings as ".part" and renamed once complete; a failed one is removed. The - * recordings themselves are never touched by a pack. + * Packs or deletes the contribution's folder on a thread of its own, at a low priority, so the panel's loop never + * waits for it. The pack is written in the folder as ".part" and renamed once complete; a failed one is + * removed. The recordings themselves are never touched by a pack. */ class Worker { public: @@ -522,19 +477,22 @@ class Worker { Worker& operator=(const Worker&) = delete; /** - * Pack a contribution into one file in its folder (tar, through zstd -3 when compressed). - * @param dir its folder - * @param info its progress + * Pack into one file in the folder (tar, through zstd at kZstdLevel when compressed). + * @param dir the folder + * @param entries what goes in (packEntries) + * @param name the file's name (packName) + * @param count the recordings in it * @param compressed through zstd - * @return false if a job runs, or what it holds can't be listed (progress says why) + * @return false if a job runs */ - bool pack(const std::string& dir, const Info& info, bool compressed); + bool pack(const std::string& dir, const std::vector& entries, const std::string& name, int count, + bool compressed); /** - * Delete a contribution's folder (only one named like one, right in home). + * Delete the contribution's folder (only home/kFolderName itself, a real folder). * @param home eyecam-rec's folder - * @param dir its folder - * @return false if a job runs, or the folder isn't a contribution's + * @param dir the folder + * @return false if a job runs, or the folder isn't that one */ bool remove(const std::string& home, const std::string& dir); @@ -562,9 +520,11 @@ class Worker { * The pack's thread. * @param dir the folder * @param entries what goes in it + * @param name the file's name * @param compressed through zstd */ - void runPack(const std::string& dir, const std::vector& entries, bool compressed); + void runPack(const std::string& dir, const std::vector& entries, const std::string& name, + bool compressed); /** * The end of a job. @@ -574,54 +534,55 @@ class Worker { void finish(bool ok, const std::string& error); }; +/** What the panel knows at a start that eyecam-rec's status doesn't say (for the recording's section). */ +struct Snapshot { + std::string appVersion; ///< frameeyeosc's version + std::string eyecamRec; ///< the first 8 hex of the installed eyecam-rec's SHA-256 ("" = unknown) + bool widen = true; ///< frameeyeosc's `widen` + bool cameraLids = true; ///< `camera_lids` +}; + /** - * The contribution as the panel's loop runs it: loads it, takes the consent and the buttons, follows its recordings - * and the re-wear between them in eyecam-rec's status, moves each recording into its folder, keeps info.txt, and runs - * the pack and the delete. Everything it shows goes into a View. + * The contribution as the panel's loop runs it: loads it, takes the consent and the start, follows the recording in + * eyecam-rec's status, checks its files and moves it into the folder, adds its section to info.txt, and runs the + * pack and the delete. Everything it shows goes into a View. */ class Session { public: /** - * Find the contribution to show (the newest in home), and go on with a recording it had started (the panel - * restarted meanwhile). + * Read the folder (home/kFolderName) and its info.txt. * @param view filled in * @param home eyecam-rec's folder */ void load(View& view, const std::string& home); /** - * "I'll help": a new contribution's folder, with the consent's time. - * @param view the contribution (none yet) - * @param version the panel's version - * @param now the time - * @return false if it couldn't be made (view.error says why) - */ - bool consent(View& view, const std::string& version, std::time_t now); - - /** - * "I put it back on": the next step's re-wear counts as done. + * The consent and the answer about glasses: the folder (made if needed), and their section in info.txt. * @param view the contribution - * @return false if no step waits for one + * @param eyewear the answer + * @param now the time + * @return false if it couldn't be written (view.error says why) */ - bool rewore(View& view); + bool consent(View& view, Eyewear eyewear, std::time_t now); /** - * The command that starts a step's recording, if it can start now (startAllowed, its turn, its re-wear done). + * The command that starts a recording, if it can start now (startAllowed). * @param view the contribution * @param eyecam eyecam-rec - * @param step the step pressed - * @return "start ", or "" when it can't + * @return "start contrib", or "" when it can't */ - std::string startCommand(const View& view, const eyecam::View& eyecam, int step) const; + std::string startCommand(const View& view, const eyecam::View& eyecam) const; /** - * The start went out: follow its recording (info.txt says so, for a restart). + * The start went out: follow its recording, with what its section will say about the time of the start. * @param view the contribution - * @param step the step + * @param eyecam eyecam-rec (its status now: the eyelid veto, the widening sensitivity, its count) + * @param snapshot what the panel knows * @param command the command sent * @param sentUnix when (Unix seconds) */ - void started(View& view, int step, const std::string& command, double sentUnix); + void started(View& view, const eyecam::View& eyecam, const Snapshot& snapshot, const std::string& command, + double sentUnix); /** * A reply from eyecam-rec (any: only the start's counts). A refused start ends that try. @@ -636,21 +597,22 @@ class Session { * @param view the contribution * @param eyecam eyecam-rec * @param nowUnix Unix seconds - * @param bootNow seconds on a clock that runs through suspend + * @param bootNow seconds on a clock that runs through suspend (how often the free space is read again) * @param log what happened, a line each (for the panel's log; "" when nothing) * @return true if the view changed */ bool follow(View& view, const eyecam::View& eyecam, double nowUnix, double bootNow, std::string& log); /** - * Pack it into one file (on the worker). + * Pack the folder into one file (on the worker). * @param view the contribution + * @param now the time (the file's name) * @return false if it can't start (view.progress says why) */ - bool pack(View& view); + bool pack(View& view, std::time_t now); /** - * Delete its folder (on the worker; not while a recording of it runs). + * Delete the folder (on the worker; not while a recording runs). * @param view the contribution * @return false if it can't */ @@ -658,25 +620,29 @@ class Session { private: RunWatch run_; - WearWatch wear_; Worker worker_; std::string command_; ///< the start waiting for its reply ("" = none) + Recording pending_; ///< the recording running, as its section will say + std::string calibText_; ///< the calibration result at its start double freeAt_ = -1e9; ///< when the free space was last read (boot seconds) /** - * Write info.txt (a failure goes to view.error). + * Add a section to info.txt and read it again (a failure goes to view.error). * @param view the contribution + * @param text the section * @return true if written */ - bool save(View& view); + bool append(View& view, const std::string& text); /** - * A recording of a step ended. + * The recording ended. * @param view the contribution * @param end how + * @param eyecam eyecam-rec (its count at the end) + * @param nowUnix when (Unix seconds) * @param log what happened */ - void ended(View& view, const RunEnd& end, std::string& log); + void ended(View& view, const RunEnd& end, const eyecam::View& eyecam, double nowUnix, std::string& log); }; /** diff --git a/panel/src/eyecam.cpp b/panel/src/eyecam.cpp index 8f26d05..9f328b1 100644 --- a/panel/src/eyecam.cpp +++ b/panel/src/eyecam.cpp @@ -93,6 +93,7 @@ Step parseStep(const std::string& label) { } kSteps[] = { {"lead_in", Step::LeadIn}, {"normal", Step::Normal}, {"widen", Step::Widen}, {"close", Step::Close}, {"squint", Step::Squint}, {"look_up", Step::LookUp}, {"look_down", Step::LookDown}, + {"look_left", Step::LookLeft}, {"look_right", Step::LookRight}, {"bright", Step::Bright}, {"dark", Step::Dark}, {"end", Step::End}, }; for (const auto& item : kSteps) { @@ -139,6 +140,12 @@ Status parseStatus(const std::string& text, double mtime) { status.protocol = readText(root, "protocol"); const JsonValue* aborted = root.get("last_session_aborted"); status.lastSessionAborted = aborted != nullptr && aborted->isBool() && aborted->boolean; + const JsonValue* offCount = root.get("headset_off_count"); + const JsonValue* started = root.get("started_unix"); + status.hasHeadsetOff = offCount != nullptr && offCount->isNumber() && started != nullptr && started->isNumber() && + offCount->number >= 0; + status.headsetOffCount = status.hasHeadsetOff ? static_cast(offCount->number) : 0; + status.startedUnix = status.hasHeadsetOff ? started->number : 0.0; // Bits beyond the two known ones are kept, but only the two are used status.calibState = std::max(0, readInt(root, "calib_state", 0)); const JsonValue* recalib = root.get("recalib_suggested"); @@ -603,6 +610,8 @@ std::string chipLabel(const UiText& t, const std::string& label) { case Step::Squint: return t.setupChipSquint; case Step::LookUp: return t.setupChipLookUp; case Step::LookDown: return t.setupChipLookDown; + case Step::LookLeft: return t.setupChipLookLeft; + case Step::LookRight: return t.setupChipLookRight; case Step::Bright: return t.setupChipBright; case Step::Dark: return t.setupChipDark; case Step::LeadIn: @@ -794,6 +803,8 @@ std::string instruction(const UiText& t, const std::string& label) { case Step::Squint: return t.eyecamStepSquint; case Step::LookUp: return t.eyecamStepLookUp; case Step::LookDown: return t.eyecamStepLookDown; + case Step::LookLeft: return t.eyecamStepLookLeft; + case Step::LookRight: return t.eyecamStepLookRight; case Step::Bright: return t.eyecamStepBright; case Step::Dark: return t.eyecamStepDark; case Step::End: return t.eyecamStepEnd; diff --git a/panel/src/eyecam.h b/panel/src/eyecam.h index 416fbdf..37c560c 100644 --- a/panel/src/eyecam.h +++ b/panel/src/eyecam.h @@ -59,7 +59,8 @@ enum class State { }; /** One step's instruction ("step_label"). */ -enum class Step { LeadIn, Normal, Widen, Close, Squint, LookUp, LookDown, Bright, Dark, End, Unknown }; +enum class Step { LeadIn, Normal, Widen, Close, Squint, LookUp, LookDown, LookLeft, LookRight, Bright, Dark, End, + Unknown }; /** The full-view overlay during the bright and dark steps. */ enum class Fill { None, Bright, Dark }; @@ -217,6 +218,9 @@ struct Status { std::string protocol; bool lastSessionAborted = false; ///< "last_session_aborted": the last recording ended early (its meta.txt says ///< aborted=1; false when missing) + bool hasHeadsetOff = false; ///< "headset_off_count" and "started_unix" are there (a newer eyecam-rec) + long long headsetOffCount = 0; ///< ...the times the headset looked taken off and was worn again since it started + double startedUnix = 0.0; ///< ...when this eyecam-rec started (Unix seconds; the count is per run) int calibState = 0; ///< kCalibWearBit | kCalibUserBit | kCalibAutoBit (0 when missing) bool recalibSuggested = false; ///< drifted since the calibration: "calib wear" again bool live = false; ///< the eyelids are read from the cameras live diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index 8ce075e..4735deb 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -592,6 +592,8 @@ UiText makeJapanese() { t.eyecamStepSquint = "目を細めて"; t.eyecamStepLookUp = "上を見て"; t.eyecamStepLookDown = "下を見て"; + t.eyecamStepLookLeft = "左の耳の方を見て"; + t.eyecamStepLookRight = "右の耳の方を見て"; t.eyecamStepBright = "明るい画面を見て"; t.eyecamStepDark = "暗い画面を見て"; t.eyecamStepEnd = "おわり"; @@ -700,6 +702,8 @@ UiText makeJapanese() { t.setupChipSquint = "細める"; t.setupChipLookUp = "上を見る"; t.setupChipLookDown = "下を見る"; + t.setupChipLookLeft = "左を見る"; + t.setupChipLookRight = "右を見る"; t.setupChipBright = "明るい所"; t.setupChipDark = "暗い所"; t.eyecamChipsMoreFormat = "ほか %d"; @@ -876,60 +880,48 @@ UiText makeJapanese() { t.devTitle = "開発用"; t.devRecord = "目の撮影"; t.devRecordHint = "目のカメラの映像を録る"; - t.contribRow = "AI の学習に協力する"; - t.contribRowHint = "半目の読み直し(AI)のための録画"; - t.contribOpen = "開く"; - t.contribRowIntroFormat = "目の映像を録って開発者に送る手伝い(任意・約 %s)"; - t.contribRowProgressFormat = "%d / %d 回録れたよ。続きからできるよ"; - t.contribRowDone = "全部録れたよ。まだなら開発者に送ってね"; + t.contribRow = "細めの学習用に撮る"; + t.contribRowHint = "半目の読み直し(AI)の学習用・任意"; + t.contribRecord = "撮る"; + t.contribRecordings = "録ったもの"; + t.contribRowIntroFormat = "目の映像を約 1 分半録って、開発者に送る手伝い(1 本 約 %s)"; + t.contribRowCountFormat = "これまで %d 本(%s)"; t.contribRowNotSetUp = "目のカメラの準備が済むと使えるよ"; - t.contribChips[0] = "決まった動き 1"; - t.contribChips[1] = "決まった動き 2"; - t.contribChips[2] = "決まった動き 3"; - t.contribChips[3] = "ふだん使い"; - t.contribChips[4] = "ふだん使い(追加)"; - t.contribLead = "「半目の読み直し(AI)」を、いろんな人の目で学習させるための録画だよ。" + t.contribLead = "「半目の読み直し(AI)」が細めや半目をもっと正しく見分けられるように、学習用の目の映像を録るよ。" "協力するかどうかは自由です。"; t.contribWhatTitle = "録るもの"; - t.contribWhatFormat = "目のカメラの映像(約 1 分を 3 回と、ふだん使い 3 分)。全部で約 %s。" - "送るまでは、この Frame の中だけにあるよ。"; + t.contribWhatFormat = "目のカメラの映像を 1 回約 1 分半(1 本 約 %s)。送るまでは、この Frame の中だけにあるよ。"; t.contribUseTitle = "使いみち"; - t.contribUse = "半目の見分けの調整と、AI の学習だけに使います。アプリに入るのは学習した AI だけで、" + t.contribUse = "細めと半目の見分けの調整と、AI の学習だけに使います。アプリに入るのは学習した AI だけで、" "映像は入りません。映像は開発者の PC にだけ置きます。"; t.contribRemoveTitle = "消してほしいとき"; t.contribRemove = "言ってくれれば消します。ただ、もう配った AI に入った学習の分は取り出せません。"; - t.contribNote = "画面が白や黒に光る段はないよ(光なしで撮るよ)。全部で 15 分くらい。" - "途中でやめても、あとで続きからできるよ。"; - t.contribSpaceFormat = "空き容量 %s(要るのは約 %s)"; - t.contribSpaceShortFormat = "空きが足りないよ。あと %s 空けてね"; - t.contribAgree = "協力する"; + t.contribSendTitle = "送り方"; + t.contribSendNote = "目の映像は GitHub の issue や公開の場所に上げないでね。開発者に非公開で送ってね" + "(送り方は開発者が伝えます)。"; + t.contribNote = "画面が白や黒に光ることはないよ(光なしで撮るよ)。"; + t.contribAgree = "同意して次へ"; t.contribCancel = "やめる"; t.contribDeleted = "録画を消したよ"; - t.contribGuidedFormat = "決まった動き(%d / %d)"; - t.contribFreeTitle = "ふだん使い(3 分)"; - t.contribFreeExtraTitle = "ふだん使い・追加(3 分)"; - t.contribRewearTitle = "HMD をいったん外して、もう一度かぶってね"; - t.contribRewearWhy = "かぶるたびにカメラの映り方が少し変わるのが、学習に大事なんだ。"; - t.contribRewearWaiting = "かぶり直したら、またこの画面を開いてね。外したのも、かぶったのも自動で分かるよ。"; - t.contribRewearOff = "外したのが分かったよ。かぶって少し待ってね"; - t.contribRewore = "かぶり直した"; - t.contribReworeHint = "自動で分からないときは、かぶり直してからこれを押してね"; - t.contribReworeWait = "かぶり直してから押せるようになるよ"; - t.contribWearDone = "かぶり直したのが分かったよ"; - t.contribGuidedNote = "ビープと画面の指示に合わせて、目を閉じる・普通・見開くを 3 回、" - "そのあと細める・上を見る・下を見るをするよ。約 1 分。"; - t.contribGuidedStart = "撮影をはじめる(約 1 分)"; - t.contribFreeTips[0] = "いつもどおり VR を使ってね。ダッシュボードは閉じて大丈夫"; - t.contribFreeTips[1] = "3〜4 回、左右の耳のほうを 3 秒くらい見てね"; - t.contribFreeTips[2] = "何回か、目を開けたまま下(手元)を見てね"; - t.contribFreeTips[3] = "話したり笑ったりして大丈夫。まばたきも自然にね"; - t.contribFreeEnd = "始まりにビープが 1 回、終わると低いビープが 2 回鳴るよ"; - t.contribFreeStart = "3 分の撮影をはじめる"; + t.contribEyewearTitle = "HMD の中で、眼鏡や度付きインサートを使ってる?"; + t.contribEyewearWhy = "学習のときに分けて見るためだけに使うよ。録画ごとに info.txt に書くよ"; + t.contribEyewear[0] = "使ってない"; + t.contribEyewear[1] = "眼鏡"; + t.contribEyewear[2] = "度付きインサート"; + t.contribEyewear[3] = "答えない"; + t.contribBack = "戻る"; + t.contribReadyTitle = "細めの学習用の撮影(約 1 分半)"; + t.contribTips[0] = "最初の 10 秒は、普通に開けて正面を見て待ってね"; + t.contribTips[1] = "ビープと画面の指示に合わせて、閉じる・普通・細める・普通・見開くを 3 回、" + "そのあと上・下・左・右を見るよ"; + t.contribTips[2] = "細めるは、まぶしいときみたいに目を細める(閉じない)"; + t.contribTips[3] = "左・右は自分から見た向き。顔は正面のまま、目だけ動かしてね"; + t.contribTips[4] = "画面が白や黒に光ることはないよ"; + t.contribCountFormat = "これまで %d 本・合計 %s"; + t.contribSpaceFormat = "空き容量 %s(1 本 約 %s)"; + t.contribStart = "撮影をはじめる(約 1 分半)"; t.contribNoSpaceFormat = "空きが足りないよ(約 %s 要る)"; t.contribWaitEyecam = "目のカメラがほかのことをしているあいだは待ってね"; - t.contribBack = "戻る"; - t.contribRunFormat = "AI の学習に協力する・%s"; - t.contribFreeRunning = "ふだんどおり使ってね"; t.contribFailedTitle = "最後まで録れなかったよ"; t.contribWhyStopped = "途中で止まったよ(中止したか、目が見えなくなったみたい)"; t.contribWhyErrorFormat = "目の撮影でエラー: %s"; @@ -938,21 +930,24 @@ UiText makeJapanese() { t.contribWhyNoStart = "撮影が始まらなかったよ"; t.contribWhyMissing = "録画のフォルダが見つからなかったよ"; t.contribWhyMoveFormat = "録画を移せなかった: %s"; + t.contribWhyIncompleteFormat = "録画のファイルが足りなかったよ(%s)"; t.contribKept = "途中までの分は aborted/ に入れたよ(送らなくて大丈夫)"; - t.contribRetry = "もう一度録る"; - t.contribDoneTitle = "全部録れたよ。ありがとう!"; - t.contribTotalFormat = "合計 %s"; + t.contribRetry = "もう一度撮る"; + t.contribDoneTitle = "撮れたよ。ありがとう!"; + t.contribManageTitle = "録ったもの"; t.contribWhereFormat = "場所: %s"; - t.contribNext = "1 つのファイルにまとめて、Google ドライブなどで開発者に送ってね。"; + t.contribAgain = "別の日や、かぶり直したあとにもう一度押してくれると、もっと助かるよ"; + t.contribNext = "1 つのファイルにまとめて、開発者に送ってね。"; t.contribPack = "1 つのファイルにまとめる"; t.contribPackHintFormat = "元の録画はそのまま残るよ(空きが約 %s 要る)"; t.contribPackingFormat = "まとめています… %d%%"; - t.contribPackedFormat = "できたよ(%s)"; + t.contribPackedFormat = "できたよ(%s 本・%s)"; t.contribPackFailedFormat = "まとめられなかった: %s"; t.contribPackNoSpaceFormat = "まとめるには空きが足りないよ(約 %s 要る)"; - t.contribExtra = "もう 1 回ふだん使いを録る(任意)"; - t.contribDelete = "この録画を消す"; - t.contribDeleteTitle = "この録画を消す?"; + t.contribIncompleteFormat = "%s はファイルが足りないので入れなかったよ。もう一度撮ってね"; + t.contribDelete = "録画を消す"; + t.contribClose = "閉じる"; + t.contribDeleteTitle = "撮った録画を全部消す?"; t.contribDeleteFormat = "%s を中身ごと(%s)消します。元に戻せないよ。開発者に送ったあとで消してね。"; t.contribDeleteYes = "消す"; t.contribDeleting = "消しています…"; @@ -1640,6 +1635,8 @@ UiText makeEnglish() { t.eyecamStepSquint = "Squint"; t.eyecamStepLookUp = "Look up"; t.eyecamStepLookDown = "Look down"; + t.eyecamStepLookLeft = "Look toward your left ear"; + t.eyecamStepLookRight = "Look toward your right ear"; t.eyecamStepBright = "Look at the bright screen"; t.eyecamStepDark = "Look at the dark screen"; t.eyecamStepEnd = "Done"; @@ -1752,6 +1749,8 @@ UiText makeEnglish() { t.setupChipSquint = "Squint"; t.setupChipLookUp = "Look up"; t.setupChipLookDown = "Look down"; + t.setupChipLookLeft = "Look left"; + t.setupChipLookRight = "Look right"; t.setupChipBright = "Bright"; t.setupChipDark = "Dark"; t.eyecamChipsMoreFormat = "+%d more"; @@ -1929,62 +1928,50 @@ UiText makeEnglish() { t.devTitle = "Developer"; t.devRecord = "Eye recording"; t.devRecordHint = "Records the eye cameras"; - t.contribRow = "Help train the AI"; - t.contribRowHint = "Recordings for the half-shut check"; - t.contribOpen = "Open"; - t.contribRowIntroFormat = "Record your eyes and send them to the developer (optional, about %s)"; - t.contribRowProgressFormat = "%d of %d recorded. You can carry on where you left off"; - t.contribRowDone = "All recorded. Send it to the developer if you haven't yet"; + t.contribRow = "Record for squint training"; + t.contribRowHint = "For the half-shut check (optional)"; + t.contribRecord = "Record"; + t.contribRecordings = "Recordings"; + t.contribRowIntroFormat = "Record your eyes for about 1.5 minutes and send it to the developer (about %s each)"; + t.contribRowCountFormat = "%d so far (%s)"; t.contribRowNotSetUp = "Available once the eye cameras are set up"; - t.contribChips[0] = "Guided 1"; - t.contribChips[1] = "Guided 2"; - t.contribChips[2] = "Guided 3"; - t.contribChips[3] = "Free use"; - t.contribChips[4] = "Free use (extra)"; - t.contribLead = "Recordings to train the \"Half-shut check (AI)\" on more people's eyes. Taking part is entirely " - "up to you."; + t.contribLead = "Eye video to train the \"Half-shut check (AI)\" to tell squints and half-shut eyes apart better. " + "Taking part is entirely up to you."; t.contribWhatTitle = "What"; - t.contribWhatFormat = "Eye camera video (about 1 minute, 3 times, and 3 minutes of normal use). About %s in all. " - "It stays on this Frame until you send it."; + t.contribWhatFormat = "Eye camera video, about 1.5 minutes each time (about %s each). It stays on this Frame until " + "you send it."; t.contribUseTitle = "How it's used"; - t.contribUse = "Only to tune the half-shut check and train its AI. Only the trained AI goes into the app, never " - "the video. The video is kept on the developer's own PCs."; + t.contribUse = "Only to tune the squint and half-shut check and train its AI. Only the trained AI goes into the " + "app, never the video. The video is kept on the developer's own PCs."; t.contribRemoveTitle = "Removing it"; t.contribRemove = "Ask and it is deleted. What an AI already released has learned from it can't be taken back " "out."; - t.contribNote = "No white or black screens (recorded without light). About 15 minutes in all. You can stop and " - "carry on later."; - t.contribSpaceFormat = "%s free (about %s needed)"; - t.contribSpaceShortFormat = "Not enough space. Free up %s more"; - t.contribAgree = "I'll help"; + t.contribSendTitle = "Sending it"; + t.contribSendNote = "Don't upload the eye video to a GitHub issue or anywhere public. Send it to the developer " + "privately (the developer will tell you how)."; + t.contribNote = "No white or black screens (recorded without light)."; + t.contribAgree = "Agree and continue"; t.contribCancel = "Cancel"; t.contribDeleted = "Recordings deleted"; - t.contribGuidedFormat = "Guided recording (%d of %d)"; - t.contribFreeTitle = "Normal use (3 minutes)"; - t.contribFreeExtraTitle = "Normal use, extra (3 minutes)"; - t.contribRewearTitle = "Take the headset off, then put it back on"; - t.contribRewearWhy = "Each wear sits the cameras a little differently, and that is what the AI needs to learn."; - t.contribRewearWaiting = "Open this screen again once it's back on. Taking it off and putting it on are noticed " - "by themselves."; - t.contribRewearOff = "Seen off. Put it back on and wait a moment"; - t.contribRewore = "I put it back on"; - t.contribReworeHint = "If it isn't noticed, press this after putting it back on"; - t.contribReworeWait = "Can be pressed once you've put it back on"; - t.contribWearDone = "Put back on: noticed"; - t.contribGuidedNote = "Follow the beeps and the prompts: close, normal and wide eyes 3 times, then squint, look up " - "and look down. About 1 minute."; - t.contribGuidedStart = "Start recording (about 1 minute)"; - t.contribFreeTips[0] = "Use VR as usual. You can close the dashboard"; - t.contribFreeTips[1] = "3 or 4 times, look far toward each ear for about 3 seconds"; - t.contribFreeTips[2] = "A few times, look down (at your hands) with your eyes open"; - t.contribFreeTips[3] = "Talking and laughing are fine. Blink naturally"; - t.contribFreeEnd = "One beep when it starts, two low beeps when it ends"; - t.contribFreeStart = "Start the 3-minute recording"; + t.contribEyewearTitle = "Do you wear glasses or prescription inserts in the headset?"; + t.contribEyewearWhy = "Only used to look at those recordings apart when training; written in info.txt with each"; + t.contribEyewear[0] = "Neither"; + t.contribEyewear[1] = "Glasses"; + t.contribEyewear[2] = "Prescription inserts"; + t.contribEyewear[3] = "Prefer not to say"; + t.contribBack = "Back"; + t.contribReadyTitle = "Recording for squint training (about 1.5 minutes)"; + t.contribTips[0] = "For the first 10 seconds, keep your eyes open as usual and look ahead"; + t.contribTips[1] = "Follow the beeps and the prompts: close, normal, squint, normal, wide 3 times, then look up, " + "down, left and right"; + t.contribTips[2] = "Squint as in bright light (don't close your eyes)"; + t.contribTips[3] = "Left and right as you see them. Keep your head still and move only your eyes"; + t.contribTips[4] = "No white or black screens"; + t.contribCountFormat = "%d so far, %s in all"; + t.contribSpaceFormat = "%s free (about %s each)"; + t.contribStart = "Start recording (about 1.5 minutes)"; t.contribNoSpaceFormat = "Not enough space (about %s needed)"; t.contribWaitEyecam = "Wait while the eye cameras are busy with something else"; - t.contribBack = "Back"; - t.contribRunFormat = "Help train the AI: %s"; - t.contribFreeRunning = "Use VR as usual"; t.contribFailedTitle = "It didn't record to the end"; t.contribWhyStopped = "It stopped early (stopped, or the eyes were lost)"; t.contribWhyErrorFormat = "Eye recording error: %s"; @@ -1993,21 +1980,24 @@ UiText makeEnglish() { t.contribWhyNoStart = "The recording didn't start"; t.contribWhyMissing = "The recording's folder wasn't found"; t.contribWhyMoveFormat = "Couldn't move the recording: %s"; + t.contribWhyIncompleteFormat = "The recording lacks files (%s)"; t.contribKept = "What was recorded is in aborted/ (no need to send it)"; t.contribRetry = "Record again"; - t.contribDoneTitle = "All recorded. Thank you!"; - t.contribTotalFormat = "%s in all"; + t.contribDoneTitle = "Recorded. Thank you!"; + t.contribManageTitle = "Recordings"; t.contribWhereFormat = "Folder: %s"; - t.contribNext = "Make it one file and share it with the developer (for example on Google Drive)."; + t.contribAgain = "Pressing it again on another day, or after taking the headset off and on, helps even more"; + t.contribNext = "Make it one file and send it to the developer."; t.contribPack = "Make one file"; t.contribPackHintFormat = "The recordings stay as they are (needs about %s free)"; t.contribPackingFormat = "Packing… %d%%"; - t.contribPackedFormat = "Done (%s)"; + t.contribPackedFormat = "Done (%s recordings, %s)"; t.contribPackFailedFormat = "Couldn't pack it: %s"; t.contribPackNoSpaceFormat = "Not enough space to pack it (about %s needed)"; - t.contribExtra = "Record normal use once more (optional)"; - t.contribDelete = "Delete these recordings"; - t.contribDeleteTitle = "Delete these recordings?"; + t.contribIncompleteFormat = "%s lacks files and was left out. Please record again"; + t.contribDelete = "Delete the recordings"; + t.contribClose = "Close"; + t.contribDeleteTitle = "Delete all these recordings?"; t.contribDeleteFormat = "Deletes %s and everything in it (%s). This can't be undone: delete it after sending it to " "the developer."; t.contribDeleteYes = "Delete"; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index 5c1924f..047a856 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -601,6 +601,8 @@ struct UiText { const char* eyecamStepSquint; const char* eyecamStepLookUp; const char* eyecamStepLookDown; + const char* eyecamStepLookLeft; ///< (as the wearer sees it) + const char* eyecamStepLookRight; const char* eyecamStepBright; const char* eyecamStepDark; const char* eyecamStepEnd; @@ -709,6 +711,8 @@ struct UiText { const char* setupChipSquint; ///< ...the user calibration's const char* setupChipLookUp; const char* setupChipLookDown; + const char* setupChipLookLeft; + const char* setupChipLookRight; const char* setupChipBright; ///< ...a recording's (the developer recording on the Advanced tab) besides these const char* setupChipDark; const char* eyecamChipsMoreFormat; ///< after a recording's chips, the steps left out at the end ("+%d") @@ -882,49 +886,38 @@ struct UiText { const char* devTitle; ///< Advanced tab: the developer section (eye recording) const char* devRecord; ///< ...its row const char* devRecordHint; - // "Help train the AI" (contrib.h): its row on Debug tools, and its sheet + // "Record for squint training" (contrib.h): its row on Debug tools, and its sheet const char* contribRow; ///< the row's title, and the sheet's const char* contribRowHint; ///< ...under the title - const char* contribOpen; ///< ...its button - const char* contribRowIntroFormat; ///< ...beside it before one is begun (%s: the size) - const char* contribRowProgressFormat; ///< ...while one goes on (%d done of %d) - const char* contribRowDone; ///< ...once all are recorded - const char* contribRowNotSetUp; ///< ...before the eye cameras are set up (the button greyed out) - const char* contribChips[5]; ///< the steps' chips under the title - const char* contribLead; ///< the consent: what it is, and that it is optional + const char* contribRecord; ///< ...its "Record" button + const char* contribRecordings; ///< ...its "Recordings" button (the folder: pack, delete) + const char* contribRowIntroFormat; ///< ...beside them before the first (%s: one recording's size) + const char* contribRowCountFormat; ///< ...after it (%d recordings, %s their size) + const char* contribRowNotSetUp; ///< ...before the eye cameras are set up (the buttons greyed out) + const char* contribLead; ///< the consent: what it is for, and that it is optional const char* contribWhatTitle; ///< ...what is recorded - const char* contribWhatFormat; ///< ...(%s: the size of all) + const char* contribWhatFormat; ///< ...(%s: one recording's size) const char* contribUseTitle; ///< ...how it is used const char* contribUse; const char* contribRemoveTitle; ///< ...deleting it later const char* contribRemove; - const char* contribNote; ///< ...no light, how long, can be continued - const char* contribSpaceFormat; ///< ...the free space (%s free, %s needed) - const char* contribSpaceShortFormat; ///< ...not enough (%s more to free) - const char* contribAgree; ///< ...the consent's button + const char* contribSendTitle; ///< ...how to send it + const char* contribSendNote; ///< ...privately, never anywhere public (also on the end's screen) + const char* contribNote; ///< ...no light, how long + const char* contribAgree; ///< ...its button const char* contribCancel; ///< ...and closing without it - const char* contribDeleted; ///< the first screen again, right after a delete - const char* contribGuidedFormat; ///< a guided step's title (%d of %d) - const char* contribFreeTitle; ///< the free use's title - const char* contribFreeExtraTitle; ///< ...the optional second one's - const char* contribRewearTitle; ///< take the headset off and put it back on - const char* contribRewearWhy; - const char* contribRewearWaiting; ///< ...not seen off yet - const char* contribRewearOff; ///< ...seen off - const char* contribRewore; ///< "I put it back on" - const char* contribReworeHint; ///< ...beside it - const char* contribReworeWait; ///< ...beside it while it can't be pressed yet - const char* contribWearDone; ///< the step's start: the re-wear is done - const char* contribGuidedNote; ///< ...what a guided recording asks - const char* contribGuidedStart; - const char* contribFreeTips[4]; ///< ...what the free use asks - const char* contribFreeEnd; ///< ...how it ends - const char* contribFreeStart; + const char* contribDeleted; ///< the consent again, right after the recordings were deleted + const char* contribEyewearTitle; ///< glasses or inserts? + const char* contribEyewearWhy; ///< ...what the answer is for + const char* contribEyewear[4]; ///< ...the answers: neither, glasses, inserts, prefer not to say + const char* contribBack; ///< ...back to the consent + const char* contribReadyTitle; ///< the recording's start: its title + const char* contribTips[5]; ///< ...what to do (the squint: as in bright light, not closing) + const char* contribCountFormat; ///< ...and on the end: how many so far (%d) and their size (%s) + const char* contribSpaceFormat; ///< ...the free space (%s free, %s one recording) + const char* contribStart; ///< ...its button const char* contribNoSpaceFormat; ///< ...the recording doesn't fit (%s needed) const char* contribWaitEyecam; ///< ...eyecam-rec busy with something else - const char* contribBack; ///< the optional step: back to the end - const char* contribRunFormat; ///< the recording's section title (%s: the step) - const char* contribFreeRunning; ///< the free use's instruction while it records const char* contribFailedTitle; ///< a try that didn't finish const char* contribWhyStopped; const char* contribWhyErrorFormat; ///< (%s: eyecam-rec's message) @@ -933,21 +926,24 @@ struct UiText { const char* contribWhyNoStart; const char* contribWhyMissing; const char* contribWhyMoveFormat; ///< (%s: why) + const char* contribWhyIncompleteFormat; ///< (%s: the files missing) const char* contribKept; ///< ...its part went to aborted/ const char* contribRetry; - const char* contribDoneTitle; ///< all recorded - const char* contribTotalFormat; ///< (%s: the size) + const char* contribDoneTitle; ///< recorded + const char* contribManageTitle; ///< the same screen from "Recordings" const char* contribWhereFormat; ///< (%s: the folder) - const char* contribNext; ///< what to do with it + const char* contribAgain; ///< ...another day or after re-wearing helps more + const char* contribNext; ///< ...what to do with it const char* contribPack; ///< "Make one file" const char* contribPackHintFormat; ///< ...beside it (%s: the size it needs free) const char* contribPackingFormat; ///< ...while packing (%d %) - const char* contribPackedFormat; ///< ...done (%s: its size; its name under it) + const char* contribPackedFormat; ///< ...done (%s recordings, %s its size; its name under it) const char* contribPackFailedFormat; ///< ...failed (%s why) const char* contribPackNoSpaceFormat; ///< ...not enough space (%s needed) - const char* contribExtra; ///< the optional free use - const char* contribDelete; ///< "Delete these recordings" - const char* contribDeleteTitle; ///< ...its confirmation + const char* contribIncompleteFormat; ///< ...recordings it left out, a file missing (%s: their names) + const char* contribDelete; ///< "Delete the recordings" + const char* contribClose; ///< the end's "Close" + const char* contribDeleteTitle; ///< ...the delete's confirmation const char* contribDeleteFormat; ///< ...(%s folder, %s size) const char* contribDeleteYes; const char* contribDeleting; diff --git a/panel/src/main.cpp b/panel/src/main.cpp index 053921c..fd7d601 100644 --- a/panel/src/main.cpp +++ b/panel/src/main.cpp @@ -164,7 +164,7 @@ struct Options { std::string fakeEyecam; ///< a made-up eyecam-rec state (see printUsage and parseFakeEyecam) std::string fakeCamera; ///< --fake-camera: the eye cameras as frameeyeosc reports them (see printUsage) std::string fakePassword = "set"; ///< --fake-password: the setup's password check - std::string fakeContrib; ///< --fake-contrib: "Help train the AI" open on a made-up screen (see fakeContrib) + std::string fakeContrib; ///< --fake-contrib: "Record for squint training" on a made-up screen (fakeContrib) double sensitivityDrag = -1; ///< --sensitivity-drag: the slider as if dragged there (-1 = not) std::string eyecamDir; ///< --eyecam-dir: eyecam-rec's folder (status.json, ctl.sock) instead of the default std::string fillPngPath; ///< --eyecam-fill-png: the eye capture's full-view light image @@ -384,11 +384,11 @@ void printUsage() { " 60). The last calibration follows setup, failed=... and calib-error\n" " --sensitivity-drag V Draw the widening sensitivity slider as if dragged to V (with sens=...)\n" " --fake-password set|unset|unknown The setup's password check (default: set)\n" - " --fake-contrib STATE \"Help train the AI\" open on a made-up screen (the eye cameras set up and idle\n" - " unless --fake-eyecam says otherwise): consent, nospace, deleted, rewear,\n" - " rewear-off (seen off; \"I put it back on\" usable), ready, ready-free, running,\n" - " running-free, failed, failed-error, done, packing, packed, extra, delete, deleting\n" - " (with --adv-page tools: its row on Debug tools in that state, the sheet closed)\n" + " --fake-contrib STATE \"Record for squint training\" open on a made-up screen (the eye cameras set up\n" + " and idle unless --fake-eyecam says otherwise): consent, deleted, eyewear,\n" + " ready, nospace, running, failed, incomplete, done (just recorded), list\n" + " (\"Recordings\"), packing, packed, delete, deleting (with --adv-page tools: its\n" + " row on Debug tools, the sheet closed; none for the row before the first)\n" " --fake-camera both|left|right|uncalibrated|absent|error|off The eye cameras as frameeyeosc reports\n" " them (off: camera_lids off)\n" " --eyecam-fill-png PATH Draw the eye capture's full-view light (with --language) to a PNG\n" @@ -459,9 +459,9 @@ bool parsePreviewSpec(const std::string& text, eye_preview::Source& source, bool } /** --fake-contrib's screens (fakeContrib). */ -const std::string kFakeContribStates[] = {"consent", "nospace", "deleted", "rewear", "rewear-off", "ready", - "ready-free", "running", "running-free", "failed", "failed-error", "done", - "packing", "packed", "extra", "delete", "deleting"}; +const std::string kFakeContribStates[] = {"none", "consent", "deleted", "eyewear", "ready", + "nospace", "running", "failed", "incomplete", "done", + "list", "packing", "packed", "delete", "deleting"}; /** --fake-eyecam, taken apart. */ struct FakeEyecam { @@ -1647,8 +1647,8 @@ eyecam::View fakeEyecam(const std::string& text) { } /** - * --fake-contrib: "Help train the AI" on one of its screens (kFakeContribStates), made up: nothing is read or written. - * The eye cameras set up and idle, unless --fake-eyecam gave eyecam-rec already; recording for the running ones. + * --fake-contrib: "Record for squint training" on one of its screens (kFakeContribStates), made up: nothing is read or + * written. The eye cameras set up and idle, unless --fake-eyecam gave eyecam-rec already; recording for "running". * @param m the model * @param state the screen * @param eyecamGiven --fake-eyecam was given @@ -1657,65 +1657,54 @@ void fakeContrib(PanelModel& m, const std::string& state, bool eyecamGiven) { contrib::View& c = m.contrib; c.loaded = true; c.home = "/home/steamos/eyecam"; - c.freeBytes = state == "nospace" ? 7'400'000'000LL : 23'200'000'000LL; + c.dir = c.home + "/" + contrib::kFolderName; + c.freeBytes = state == "nospace" ? 3'100'000'000LL : 23'200'000'000LL; c.fps = 90.0; if (!eyecamGiven) m.eyecam = fakeEyecam("idle:setup:auto=ok:buffers:ready:saved"); - if (state == "consent" || state == "nospace" || state == "deleted") { + if (state == "none" || state == "consent" || state == "deleted" || state == "eyewear") { c.deleted = state == "deleted"; return; } - c.dir = "/home/steamos/eyecam/contrib_2026-10-25_14-02-10"; - c.info.appVersion = "0.8.2-beta.2"; + c.exists = true; + c.info.consentVersion = contrib::kConsentVersion; c.info.consent = "2026-10-25 14:02:10 +0900"; - // The steps done: one before the second guided recording, three before the free use, all for the end - const bool end = state == "done" || state == "packing" || state == "packed" || state == "extra" || - state == "delete" || state == "deleting"; - const int done = end ? contrib::kRequiredSteps : (state == "ready-free" || state == "running-free") ? 3 : 1; - const char* const recs[] = {"rec_2026-10-25_14-02-41", "rec_2026-10-25_14-05-12", "rec_2026-10-25_14-08-03", - "rec_2026-10-25_14-11-40"}; - for (int step = 0; step < contrib::kStepCount; ++step) { - contrib::Step& s = c.info.steps[step]; - s.protocol = contrib::protocolOf(step); - if (step >= done) continue; - s.rec = recs[step]; - s.bytes = contrib::estimateBytes(step, 90.0) - 3'000'000; - s.wear = step == 0 ? contrib::kWearFirst : contrib::kWearDetected; - s.done = "2026-10-25 14:0" + std::to_string(3 + step * 3) + ":44"; + c.info.eyewear = contrib::Eyewear::None; + // Three recordings so far (and a fourth just now on the end's screen) + const char* const recs[] = {"rec_2026-10-23_21-14-05", "rec_2026-10-24_22-30-41", "rec_2026-10-25_14-03-12", + "rec_2026-10-25_14-20-55"}; + const bool justDone = state == "done" || state == "packing" || state == "packed" || state == "delete"; + for (int i = 0; i < (justDone ? 4 : 3); ++i) { + contrib::Recording r; + r.rec = recs[i]; + r.protocol = contrib::kProtocol; + r.bytes = contrib::estimateBytes(90.0) - 4'000'000; + c.info.recordings.push_back(r); } c.totalBytes = contrib::recordedBytes(c.info); - if (state != "rewear" && state != "rewear-off") c.info.nextWear = contrib::kWearDetected; - c.offSeen = state == "rewear-off"; - c.fallbackReady = state == "rewear-off"; - if (state == "running" || state == "running-free") { - c.running = done; - if (state == "running") { - m.eyecam = fakeEyecam("recording:widen:nolight"); - } else { - // The free use: protocol_free.txt's one step of 180 s, a minute in - m.eyecam = fakeEyecam("recording:normal"); - eyecam::Status& s = m.eyecam.status; - s.protocol = contrib::kFreeProtocol; - s.stepCount = 1; - s.stepIndex = 0; - s.totalS = contrib::kFreeSec + contrib::kTailSec; - s.elapsedS = 63.0; - s.stepRemainingS = contrib::kFreeSec - 63.0; - m.eyecam.protocol.name = s.protocol; - m.eyecam.protocol.steps = {"normal"}; - } + c.justDone = justDone; + if (state == "running") { + m.eyecam = fakeEyecam("recording:squint"); + // (protocol_contrib.txt's steps: the second squint, 35 s in) + eyecam::Status& s = m.eyecam.status; + s.protocol = contrib::kProtocol; + m.eyecam.protocol.name = s.protocol; + m.eyecam.protocol.steps = {"lead_in", "normal", "close", "normal", "squint", "normal", "widen", + "close", "normal", "squint", "normal", "widen", "close", "normal", + "squint", "normal", "widen", "look_up", "look_down", "look_left", "normal", + "look_right", "normal"}; + s.stepCount = static_cast(m.eyecam.protocol.steps.size()); + s.stepIndex = 9; + s.stepLabel = "squint"; + s.totalS = contrib::kProtocolSec + contrib::kTailSec; + s.elapsedS = 36.6; + s.stepRemainingS = 2.4; + c.running = true; } - if (state == "failed" || state == "failed-error") { - c.failure.step = done; - if (state == "failed") { - c.failure.why = contrib::Why::Stopped; - c.failure.kept = "aborted/rec_2026-10-25_14-05-12"; - } else { - c.failure.why = contrib::Why::Error; - // (eyecam-rec's own, with its English) - c.failure.message = "ディスクの空きが 1 GB 未満(途中まで保存: rec_2026-10-25_14-05-12)"; - c.failure.messageEn = "Less than 1 GB of disk space left (saved so far: rec_2026-10-25_14-05-12)"; - c.failure.kept = "aborted/rec_2026-10-25_14-05-12"; - } + if (state == "failed") { + c.failure = {true, contrib::Why::Stopped, "", "", "aborted/rec_2026-10-25_14-20-55"}; + } + if (state == "incomplete") { + c.failure = {true, contrib::Why::Incomplete, "cues.csv", "cues.csv", "aborted/rec_2026-10-25_14-20-55"}; } if (state == "packing") { c.progress.job = contrib::Job::Pack; @@ -1724,10 +1713,12 @@ void fakeContrib(PanelModel& m, const std::string& state, bool eyecamGiven) { c.progress.done = c.totalBytes * 45 / 100; } if (state == "packed") { - c.info.packed = "contrib_2026-10-25_14-02-10.tar.zst"; - c.info.packedBytes = c.totalBytes * 62 / 100; + c.info.packed = "contrib_2026-10-25_14-25-02.tar.zst"; + c.info.packedCount = 4; + c.info.packedBytes = c.totalBytes * 58 / 100; c.totalBytes += c.info.packedBytes; } + if (state == "list") c.incomplete = {"rec_2026-10-24_22-30-41"}; if (state == "deleting") { c.progress.job = contrib::Job::Delete; c.progress.running = true; @@ -2358,9 +2349,10 @@ int runDumpPng(const Options& options) { if (options.fakeEyecam.rfind("confirm", 0) == 0) panel.openEyecamConfirm(model.eyecam.status.state); // (with --adv-page tools: its row on Debug tools, the sheet closed) if (!options.fakeContrib.empty() && options.advPage != "tools") { - panel.openContrib(); - panel.setContribExtra(options.fakeContrib == "extra"); - if (options.fakeContrib == "delete") panel.openContribDelete(); + const std::string& state = options.fakeContrib; + panel.openContrib(state == "list" || state == "deleting" ? contrib::Page::Manage : contrib::Page::Record); + panel.setContribAgreed(state == "eyewear"); + if (state == "delete") panel.openContribDelete(); } if (!options.appsSheet.empty()) { const std::string& sheet = options.appsSheet; @@ -2918,7 +2910,7 @@ void startFit(gaze_fit::Session& fit, gaze_fit::Mode mode, const SettingsView& v * @param vr the connection to SteamVR, for the IPD (null in --dump-png) * @param eyeLog the eye log (null in --dump-png) * @param eyecamControl eyecam-rec's control socket (null: its buttons do nothing) - * @param contribSession "Help train the AI" (null in --dump-png: its buttons only say what they would do) + * @param contribSession "Record for squint training" (null in --dump-png: its buttons only say what they would do) */ void applyHit(const PanelHit& hit, PanelModel& model, EyePanel& panel, AutostartWorker& autostart, frame_updater::UpdateChecker* updater, gaze_fit::Session* fit, const VrOverlay* vr, @@ -3299,49 +3291,59 @@ void applyHit(const PanelHit& hit, PanelModel& model, EyePanel& panel, Autostart return; } case PanelAction::ContribClose: - case PanelAction::ContribExtra: + case PanelAction::ContribAgree: case PanelAction::ContribDelete: case PanelAction::ContribDeleteCancel: return; // handled inside the panel case PanelAction::ContribOpen: - // Read from the disk the first time (the loop reads it at its start; it follows it from then on) + // Read from the disk the first time (the loop reads it at its start and follows it from then on); each + // opening starts afresh: the last recording's end and failure are not shown again if (contribSession != nullptr && !model.contrib.loaded) { contribSession->load(model.contrib, contrib::defaultHome()); } - std::fprintf(stderr, "[contrib] opened: %s\n", - model.contrib.dir.empty() ? "none yet" : recorder::shortPath(model.contrib.dir).c_str()); + model.contrib.justDone = false; + model.contrib.failure = contrib::Failure(); + model.contrib.deleted = false; + std::fprintf(stderr, "[contrib] opened (%s): %d recording(s) in %s\n", + hit.arg == 0 ? "record" : "recordings", + static_cast(model.contrib.info.recordings.size()), + recorder::shortPath(model.contrib.dir).c_str()); return; - case PanelAction::ContribConsent: + case PanelAction::ContribEyewear: { + const contrib::Eyewear eyewear = static_cast(hit.arg); if (contribSession == nullptr) { - std::printf("contrib: would make a contribution's folder in %s\n", contrib::defaultHome().c_str()); + std::printf("contrib: would write the consent (eyewear %s)\n", contrib::eyewearName(eyewear)); return; } - if (contribSession->consent(model.contrib, FRAMEEYEOSC_VERSION, std::time(nullptr))) { - std::fprintf(stderr, "[contrib] consent given: %s\n", recorder::shortPath(model.contrib.dir).c_str()); + if (contribSession->consent(model.contrib, eyewear, std::time(nullptr))) { + std::fprintf(stderr, "[contrib] consent given (version %d, eyewear %s)\n", contrib::kConsentVersion, + contrib::eyewearName(eyewear)); } else { - std::fprintf(stderr, "[contrib] couldn't begin: %s\n", model.contrib.error.c_str()); - } - return; - case PanelAction::ContribRewore: - if (contribSession != nullptr && contribSession->rewore(model.contrib)) { - std::fprintf(stderr, "[contrib] re-wear: the user said so\n"); + std::fprintf(stderr, "[contrib] couldn't write the consent: %s\n", model.contrib.error.c_str()); } return; + } case PanelAction::ContribStart: { - // The step's protocol to eyecam-rec's socket (its reply and its run are followed by the loop) + // protocol_contrib.txt to eyecam-rec's socket (its reply and its run are followed by the loop) if (contribSession == nullptr || eyecamControl == nullptr) { - std::printf("contrib: would start step %d (%s)\n", hit.arg + 1, contrib::protocolOf(hit.arg)); + std::printf("contrib: would send \"start %s\"\n", contrib::kProtocol); return; } - const std::string command = contribSession->startCommand(model.contrib, model.eyecam, hit.arg); + const std::string command = contribSession->startCommand(model.contrib, model.eyecam); if (command.empty()) { - std::fprintf(stderr, "[contrib] step %d can't start now\n", hit.arg + 1); + std::fprintf(stderr, "[contrib] can't start now\n"); return; } + // What the recording's section says that eyecam-rec's status doesn't + static diag::FileHash recHash; + contrib::Snapshot snapshot; + snapshot.appVersion = FRAMEEYEOSC_VERSION; + snapshot.eyecamRec = recHash.get(eyecam::libDir() + "/eyecam-rec"); + snapshot.widen = view.flag(key::kWiden); + snapshot.cameraLids = view.flag(key::kCameraLids); const std::string socket = model.eyecamDir + "/ctl.sock"; - std::fprintf(stderr, "[contrib] step %d: sending \"%s\" to %s\n", hit.arg + 1, command.c_str(), - socket.c_str()); + std::fprintf(stderr, "[contrib] sending \"%s\" to %s\n", command.c_str(), socket.c_str()); const bool sent = eyecamControl->send(socket, command, nowSeconds()); - contribSession->started(model.contrib, hit.arg, command, unixNow()); + contribSession->started(model.contrib, model.eyecam, snapshot, command, unixNow()); if (!sent && !eyecamControl->busy()) { std::fprintf(stderr, "[contrib] %s failed: %s\n", command.c_str(), eyecamControl->reply().error.c_str()); @@ -3355,7 +3357,7 @@ void applyHit(const PanelHit& hit, PanelModel& model, EyePanel& panel, Autostart std::printf("contrib: would pack %s\n", model.contrib.dir.c_str()); return; } - if (contribSession->pack(model.contrib)) { + if (contribSession->pack(model.contrib, std::time(nullptr))) { std::fprintf(stderr, "[contrib] packing %s\n", recorder::shortPath(model.contrib.dir).c_str()); } else { std::fprintf(stderr, "[contrib] can't pack: %s\n", model.contrib.progress.error.c_str()); @@ -3841,13 +3843,12 @@ int runOverlay(const Options& options) { eyecam::Light light; // the light as shown: which one, and its alpha as it fades in and out eyecam::Fill lightWanted = eyecam::Fill::None; // the one fillFor wanted last time (to log a fade-out once) bool lightMoving = false; // fading in or out: the loop runs every display frame - // "Help train the AI": the contribution in ~/eyecam (a recording it had started goes on being followed) + // "Record for squint training": its folder in ~/eyecam contrib::Session contribSession; contribSession.load(model.contrib, contrib::defaultHome()); - if (!model.contrib.dir.empty()) { - std::fprintf(stderr, "[contrib] %s: %d of %d recorded%s\n", recorder::shortPath(model.contrib.dir).c_str(), - contrib::requiredDone(model.contrib.info), contrib::kRequiredSteps, - model.contrib.running >= 0 ? ", one recording on its way" : ""); + if (model.contrib.exists) { + std::fprintf(stderr, "[contrib] %s: %d recording(s)\n", recorder::shortPath(model.contrib.dir).c_str(), + static_cast(model.contrib.info.recordings.size())); } // The debug gaze dots: their socket, the three dot images and the three ring images after them (drawn once), which // image each overlay has (0 and 1 the dots, 2 and 3 the rings: the gaze before the user's gaze scale) @@ -4545,10 +4546,9 @@ int runOverlay(const Options& options) { if (calibRun.active() && r.command == calibCommand && calibWatch.replied(r.ok)) { finishCalib(r.error.empty() ? std::string("err") : r.error); } - // A step of "Help train the AI" it refused: that try ends + // A recording for squint training it refused: that try ends if (contribSession.replied(model.contrib, r)) { - std::fprintf(stderr, "[contrib] step %d refused: %s\n", model.contrib.failure.step + 1, - r.error.c_str()); + std::fprintf(stderr, "[contrib] start refused: %s\n", r.error.c_str()); dirty = true; } // A sensitivity it refused: the slider shows the file's value again (the reply shows like others) @@ -4590,8 +4590,8 @@ int runOverlay(const Options& options) { } } syncEyecamControl(model.eyecam, eyecamControl); - // "Help train the AI": its recording's end (the folder moved into the contribution), the re-wear before the - // next step (watched with the dashboard closed too), and the pack or the delete + // "Record for squint training": its recording's end (checked and moved into its folder; watched with the + // dashboard closed too), and the pack or the delete if (statusRead) { std::string log; if (contribSession.follow(model.contrib, model.eyecam, now, bootSeconds(), log)) dirty = true; diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index cf3d819..2d1b29d 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -650,16 +650,6 @@ std::string formatLong(const char* format, const std::string& first, const std:: return text; } -/** - * A step of "Help train the AI" as its recording's section title. - * @param t texts - * @param step the step - * @return "Help train the AI: Guided 2" - */ -std::string contribRunTitle(const UiText& t, int step) { - return formatLong(t.contribRunFormat, t.contribChips[std::clamp(step, 0, contrib::kStepCount - 1)]); -} - std::string cameraUseText(const UiText& t, CameraUse use, const EyeStatus& s) { std::string text; switch (use) { @@ -1093,15 +1083,24 @@ PanelHit EyePanel::pointerDown(double x, double y, double now) { numValue_ = numberAt(numKey_, x); return {}; case PanelAction::ContribOpen: - // The caller reads the contribution again - openContrib(); + // The caller forgets the last recording's end and failure (each press of "Record" starts afresh) + openContrib(hit.arg == static_cast(contrib::Page::Manage) ? contrib::Page::Manage + : contrib::Page::Record); return hit; case PanelAction::ContribClose: closeContrib(); return {}; - case PanelAction::ContribExtra: - contribExtra_ = hit.arg != 0; + case PanelAction::ContribAgree: + contribAgreed_ = hit.arg != 0; return {}; + case PanelAction::ContribEyewear: + // The caller writes the consent with the answer + if (hit.arg <= static_cast(contrib::Eyewear::Unasked) || + hit.arg > static_cast(contrib::Eyewear::NoAnswer)) { + return {}; + } + contribAgreed_ = false; + return hit; case PanelAction::ContribDelete: contribConfirm_ = true; return {}; @@ -1109,10 +1108,10 @@ PanelHit EyePanel::pointerDown(double x, double y, double now) { contribConfirm_ = false; return {}; case PanelAction::ContribDeleteConfirm: - // The caller deletes it; the sheet shows the deleting, then its first screen + // The caller deletes it; the sheet shows the deleting, then the consent again if (!contribConfirm_) return {}; contribConfirm_ = false; - contribExtra_ = false; + contribPage_ = contrib::Page::Record; return hit; case PanelAction::EyecamChoose: if (!eyecamConfirm_.isOpen()) return {}; @@ -1276,7 +1275,7 @@ void EyePanel::openRecordsAll() { eyecamConfirm_.close(); } -void EyePanel::openContrib() { +void EyePanel::openContrib(contrib::Page page) { setTab(PanelTab::Advanced); // (from "Debug tools": its cross comes back to it) setAdvPage(AdvPage::Tools); @@ -1287,8 +1286,9 @@ void EyePanel::openContrib() { eyecamConfirm_.close(); closeAppsList(); contribOpen_ = true; + contribPage_ = page; contribConfirm_ = false; - contribExtra_ = false; + contribAgreed_ = false; } void EyePanel::openAppsList() { @@ -3746,22 +3746,17 @@ void EyePanel::drawAdvanced(const Pen& pen, const UiText& t, const PanelModel& m } return; } - // "Help train the AI": its sheet in the tab's place (its recordings run in it) + // "Record for squint training": its sheet in the tab's place (its recording runs in it) if (contribOpen_) { drawContrib(pen, t, m); return; } - // The developer recording: its light warning, and its steps while it runs, in the tab's place (a step of "Help - // train the AI" with its sheet closed: titled with that step) + // The developer recording: its light warning, and its steps while it runs, in the tab's place (one for squint + // training with its sheet closed: titled with that) const eyecam::State recording = m.eyecam.status.state; if (eyecamConfirm_.isOpen() || (m.eyecam.visible && (recording == eyecam::State::Searching || recording == eyecam::State::Recording))) { - const int step = m.contrib.running; - if (step >= 0 && step < contrib::kStepCount) { - drawRun(pen, t, m, contribRunTitle(t, step), contrib::isFree(step) ? t.contribFreeRunning : nullptr); - } else { - drawRun(pen, t, m); - } + drawRun(pen, t, m, m.contrib.running ? t.contribRow : ""); return; } // The sub-tabs, and under them the one chosen (scrolled only if it doesn't fit) @@ -4646,7 +4641,7 @@ double EyePanel::drawToolsPage(const Pen& pen, const UiText& t, const PanelModel } } y += h; - // Under it, "Help train the AI": recordings for the developer (usable once the cameras are set up) + // Under it, "Record for squint training": recordings for the developer (usable once the cameras are set up) y += 10; y += drawContribRow(pen, t, m, y, right); } @@ -4659,25 +4654,28 @@ double EyePanel::drawContribRow(const Pen& pen, const UiText& t, const PanelMode const double bh = 46; const bool setUp = eyecam::setUp(m.eyecam); drawRowLabel(pen, t, y, h, t.contribRow, t.contribRowHint, false); - const double bw = 160; - drawButton(pen, kControlX, y + (h - bh) / 2, bw, bh, t.contribOpen, {PanelAction::ContribOpen, nullptr, 0}, setUp, - false); - // Beside it: what it is (and its size), how far it is, or why it can't be used yet + // "Record", and "Recordings" once there is a folder + const double recordW = std::max(110.0, pen.measure(t.contribRecord, 18, true) + 40); + const double listW = std::max(130.0, pen.measure(t.contribRecordings, 16, true) + 36); + drawButton(pen, kControlX, y + (h - bh) / 2, recordW, bh, t.contribRecord, + {PanelAction::ContribOpen, nullptr, static_cast(contrib::Page::Record)}, setUp, false); + drawButton(pen, kControlX + recordW + 10, y + (h - bh) / 2, listW, bh, t.contribRecordings, + {PanelAction::ContribOpen, nullptr, static_cast(contrib::Page::Manage)}, setUp && c.exists, + false, 16); + // Beside them: what it is (and its size), how many so far, or why it can't be used yet std::string text; + const int count = static_cast(c.info.recordings.size()); if (!setUp) { text = t.contribRowNotSetUp; - } else if (c.dir.empty()) { - text = formatLong(t.contribRowIntroFormat, - contrib::sizeText(contrib::neededBytes(contrib::Info(), c.fps) - contrib::kMarginBytes)); - } else if (contrib::requiredDone(c.info) >= contrib::kRequiredSteps) { - text = t.contribRowDone; + } else if (count == 0) { + text = formatLong(t.contribRowIntroFormat, contrib::sizeText(contrib::estimateBytes(c.fps))); } else { char line[256]; - std::snprintf(line, sizeof(line), t.contribRowProgressFormat, contrib::requiredDone(c.info), - contrib::kRequiredSteps); + std::snprintf(line, sizeof(line), t.contribRowCountFormat, count, + contrib::sizeText(contrib::recordedBytes(c.info)).c_str()); text = line; } - const double textX = kControlX + bw + 14; + const double textX = kControlX + recordW + 10 + listW + 14; const std::vector lines = wrapText(pen, text, 13, false, right - textX, 3); double baseline = y + h / 2 - (lines.size() - 1) * 8.5 + 5; for (const std::string& line : lines) { @@ -5495,8 +5493,7 @@ void EyePanel::drawHostEntry(const Pen& pen, const UiText& t) { } } -void EyePanel::drawRun(const Pen& pen, const UiText& t, const PanelModel& m, const std::string& title, - const char* instruction) { +void EyePanel::drawRun(const Pen& pen, const UiText& t, const PanelModel& m, const std::string& title) { using eyecam::State; const eyecam::View& view = m.eyecam; const eyecam::Status& s = view.status; @@ -5607,15 +5604,13 @@ void EyePanel::drawRun(const Pen& pen, const UiText& t, const PanelModel& m, con true); // A recording's steps under it, as chips (as many as fit around the one now; none when eyecam's protocol // file can't be read), and everything below that lower down - const eyecam::StepChips chips = - calibrating || instruction != nullptr ? eyecam::StepChips() : eyecam::recordingChips(view); + const eyecam::StepChips chips = calibrating ? eyecam::StepChips() : eyecam::recordingChips(view); if (!chips.labels.empty()) { drawRunChips(pen, t, chips, y + 66, width); y += 40; } // The instruction, as large as it fits - centered(y + 150, instruction != nullptr ? std::string(instruction) : eyecam::instruction(t, s.stepLabel), - 96, 36, kText, true); + centered(y + 150, eyecam::instruction(t, s.stepLabel), 96, 36, kText, true); // The seconds left of the step; while the cameras lost the eyes (the headset came off; the time runs on), // a call to put it back on in their place if (!s.locked) { @@ -7031,38 +7026,31 @@ void EyePanel::drawEyecamConfirm(const Pen& pen, const UiText& t, const eyecam:: void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) { using contrib::Screen; const contrib::View& c = m.contrib; - const Screen screen = contrib::screen(c, contribExtra_); - // Its recording: the eye capture's run, titled with the step (its "Stop" as always); until eyecam-rec shows it - // running (or after it ended, until the loop has followed that), only that it is on its way + const Screen screen = contrib::screen(c, contribPage_, contribAgreed_); + // The recording: the eye capture's run, titled with it (its "Stop" as always); until eyecam-rec shows it running + // (or after it ended, until the loop has followed that), only that it is on its way if (screen == Screen::Running) { const eyecam::State state = m.eyecam.status.state; if (state == eyecam::State::Searching || state == eyecam::State::Recording) { - drawRun(pen, t, m, contribRunTitle(t, c.running), - contrib::isFree(c.running) ? t.contribFreeRunning : nullptr); + drawRun(pen, t, m, t.contribRow); } else { - drawSectionTitle(pen, kRowTop - 6, contribRunTitle(t, c.running)); + drawSectionTitle(pen, kRowTop - 6, t.contribRow); textCentered(pen, (kInnerX + kInnerRight) / 2, kContentY + kContentH / 2, t.eyecamSending, 28, kTextMuted, true); } return; } - const int step = contrib::nextStep(c.info, contribExtra_); const double width = kInnerRight - kInnerX; const double bottom = kContentY + kContentH - 20; const bool jobRunning = c.progress.running; + const int count = static_cast(c.info.recordings.size()); - // The title and the cross; on a step's screens "Delete these recordings" beside the cross + // The title and the cross const double closeS = 52; const double closeX = kInnerRight - closeS; const double headY = kContentY + 20; drawSheetClose(pen, {PanelAction::ContribClose, nullptr, 0}, closeX, headY + 2, closeS); - double titleRight = closeX - 16; - if (screen == Screen::Rewear || screen == Screen::Ready || screen == Screen::Failed) { - const double dw = pen.measure(t.contribDelete, 15, true) + 36; - titleRight -= dw + 12; - drawButton(pen, closeX - 12 - dw, headY + 8, dw, 40, t.contribDelete, {PanelAction::ContribDelete, nullptr, 0}, - !jobRunning, false, 15); - } + const double titleRight = closeX - 16; pen.text(kInnerX, headY + 26, t.contribRow, fitSize(pen, t.contribRow, 22, 16, titleRight - kInnerX, true), kText, true); pen.text(kInnerX, headY + 50, ellipsize(pen, t.contribRowHint, 14, false, titleRight - kInnerX, false), 14, @@ -7085,38 +7073,23 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) y += size * 1.4; } }; - // The steps as chips (done: a check, the next one: the accent), the optional one once it is asked for or done - if (screen != Screen::Consent) { - eyecam::StepChips chips; - const bool extraShown = step == contrib::kRequiredSteps || !c.info.steps[contrib::kRequiredSteps].rec.empty(); - const int shown = extraShown ? contrib::kStepCount : contrib::kRequiredSteps; - for (int i = 0; i < shown; ++i) chips.labels.push_back(t.contribChips[i]); - chips.current = screen == Screen::Done ? shown : step; - drawRunChips(pen, t, chips, y + 26, width); - y += 52; - } - // A failure on the disk (info.txt, a folder), above everything else - if (!c.error.empty()) paragraph(formatLong(t.contribErrorFormat, c.error), 15, kDanger, true, 2); - - // A step's title (and, after its re-wear, that it is done) - const auto stepTitle = [&]() { - std::string title; - if (contrib::isFree(step)) { - title = step >= contrib::kRequiredSteps ? t.contribFreeExtraTitle : t.contribFreeTitle; - } else { - char text[128]; - std::snprintf(text, sizeof(text), t.contribGuidedFormat, step + 1, 3); - title = text; - } - const double titleW = pen.text(kInnerX, y + 24, title, 24, kText, true); - if (screen == Screen::Ready && contrib::needsRewear(step)) { - const double x = kInnerX + titleW + 24; - drawCheck(pen.cr, x + 8, y + 16, 16, kSuccess); - pen.text(x + 24, y + 22, ellipsize(pen, t.contribWearDone, 16, true, kInnerRight - x - 24, false), 16, - kSuccess, true); + // How to send it, in a box with the warning's edge: privately, never anywhere public + const auto sendNote = [&](double size) { + const double pad = 14; + const std::vector lines = wrapText(pen, t.contribSendNote, size, false, width - pad * 2 - 30, 3); + const double boxH = pad * 2 + lines.size() * size * 1.4 - size * 0.4; + fillRounded(pen, kInnerX, y, width, boxH, 12, kWarningTint); + strokeRounded(pen, kInnerX, y, width, boxH, 12, kWarning, 1.5); + drawInfoIcon(pen, kInnerX + pad + 8, y + pad + size * 0.55, kWarning); + double baseline = y + pad + size * 0.85; + for (const std::string& line : lines) { + pen.text(kInnerX + pad + 30, baseline, line, size, kWarningText); + baseline += size * 1.4; } - y += 44; + y += boxH; }; + // A failure on the disk (info.txt, a folder), above everything else + if (!c.error.empty()) paragraph(formatLong(t.contribErrorFormat, c.error), 15, kDanger, true, 2); // At the bottom right: the screen's main button const double mainH = 60; const double mainY = bottom - mainH; @@ -7125,17 +7098,27 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) drawButton(pen, kInnerRight - w, mainY, w, mainH, label, hit, usable, accent, 22); return kInnerRight - w; }; - // Above it: why it can't be pressed (no room, eyecam-rec busy) - const auto whyNot = [&](int forStep) { - if (!contrib::fits(forStep, c.freeBytes, c.fps)) { - const long long need = contrib::estimateBytes(forStep, c.fps) + contrib::kMarginBytes; + // Above it: why "Start" can't be pressed (no room, eyecam-rec busy), else the free space + const auto startState = [&]() { + if (!contrib::fits(c.freeBytes, c.fps)) { + const long long need = contrib::estimateBytes(c.fps) + contrib::kMarginBytes; pen.text(kInnerX, mainY - 16, formatLong(t.contribNoSpaceFormat, contrib::sizeText(need)), 16, kDanger, true); - } else if (!contrib::startAllowed(c, m.eyecam, forStep)) { + } else if (!contrib::startAllowed(c, m.eyecam)) { pen.text(kInnerX, mainY - 16, ellipsize(pen, t.contribWaitEyecam, 16, false, width, false), 16, kTextMuted); + } else if (c.freeBytes >= 0) { + const std::string line = formatLong(t.contribSpaceFormat, contrib::sizeText(c.freeBytes), + contrib::sizeText(contrib::estimateBytes(c.fps))); + pen.text(kInnerX, mainY - 16, ellipsize(pen, line, 16, false, width, false), 16, kTextMuted); } }; + const auto countLine = [&]() { + char text[256]; + std::snprintf(text, sizeof(text), t.contribCountFormat, count, + contrib::sizeText(contrib::recordedBytes(c.info)).c_str()); + return std::string(text); + }; switch (screen) { case Screen::Consent: { @@ -7145,11 +7128,10 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) y += 30; } paragraph(t.contribLead, 18, kText, false, 2); - y += 10; + y += 8; // What, how it's used, removing it: a title at the left, its text beside it - const long long all = contrib::neededBytes(contrib::Info(), c.fps) - contrib::kMarginBytes; const std::string blocks[3][2] = { - {t.contribWhatTitle, formatLong(t.contribWhatFormat, contrib::sizeText(all))}, + {t.contribWhatTitle, formatLong(t.contribWhatFormat, contrib::sizeText(contrib::estimateBytes(c.fps)))}, {t.contribUseTitle, t.contribUse}, {t.contribRemoveTitle, t.contribRemove}, }; @@ -7161,96 +7143,58 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) pen.text(kInnerX + 10, y + 18, block[0], 16, kText, true); const double top = y; paragraph(block[1], 16, kText, false, 3, textX); - y = std::max(y, top + 26) + 8; + y = std::max(y, top + 26) + 6; } - y += 4; - paragraph(t.contribNote, 15, kTextMuted, false, 2); - // The free space against what it needs - const long long need = contrib::neededBytes(c.info, c.fps); - const bool enough = c.freeBytes < 0 || c.freeBytes >= need; - if (c.freeBytes >= 0) { - const std::string line = - enough ? formatLong(t.contribSpaceFormat, contrib::sizeText(c.freeBytes), contrib::sizeText(need)) - : formatLong(t.contribSpaceShortFormat, contrib::sizeText(need - c.freeBytes)); - pen.text(kInnerX, mainY - 18, ellipsize(pen, line, 17, !enough, width, false), 17, - enough ? kTextMuted : kDanger, !enough); - } - const double agreeX = mainButton(t.contribAgree, {PanelAction::ContribConsent, nullptr, 0}, - enough && eyecam::setUp(m.eyecam), true); + sendNote(16); + y += 8; + paragraph(t.contribNote, 15, kTextMuted, false, 1); + const double agreeX = mainButton(t.contribAgree, {PanelAction::ContribAgree, nullptr, 1}, + eyecam::setUp(m.eyecam), true); const double cancelW = std::max(140.0, pen.measure(t.contribCancel, 20, true) + 48); drawButton(pen, agreeX - 14 - cancelW, mainY, cancelW, mainH, t.contribCancel, {PanelAction::ContribClose, nullptr, 0}, true, false, 20); break; } - case Screen::Rewear: { - stepTitle(); - // The ask, in a box: large, why, and what was seen so far - const double pad = 22; - const double boxTop = y; - const double innerW = width - pad * 2; - const std::vector ask = wrapText(pen, t.contribRewearTitle, 28, true, innerW, 2); - const std::vector why = wrapText(pen, t.contribRewearWhy, 16, false, innerW, 2); - const char* seen = c.offSeen ? t.contribRewearOff : t.contribRewearWaiting; - const std::vector state = wrapText(pen, seen, 17, c.offSeen, innerW - 26, 2); - const double boxH = pad * 2 + ask.size() * 38 + 6 + why.size() * 22 + 14 + state.size() * 24; - fillRounded(pen, kInnerX, boxTop, width, boxH, 16, kBg); - strokeRounded(pen, kInnerX, boxTop, width, boxH, 16, kAccent, 2); - double baseline = boxTop + pad + 28; - for (const std::string& line : ask) { - pen.text(kInnerX + pad, baseline, line, 28, kText, true); - baseline += 38; - } - baseline += 6 - 38 + 22; - for (const std::string& line : why) { - pen.text(kInnerX + pad, baseline, line, 16, kTextMuted); - baseline += 22; - } - baseline += 14 - 22 + 24; - drawDot(pen.cr, kInnerX + pad + 8, baseline - 6, 6, c.offSeen ? kAccent : kTextMuted); - for (const std::string& line : state) { - pen.text(kInnerX + pad + 26, baseline, line, 17, c.offSeen ? kText : kTextMuted, c.offSeen); - baseline += 24; - } - // "I put it back on" (a while after the step began waiting), with what it is for beside it - const double reworeX = mainButton(t.contribRewore, {PanelAction::ContribRewore, nullptr, 0}, - c.fallbackReady && !jobRunning, false); - const char* hint = c.fallbackReady ? t.contribReworeHint : t.contribReworeWait; - const std::vector lines = wrapText(pen, hint, 15, false, reworeX - 20 - kInnerX, 2); - double hintBaseline = mainY + mainH / 2 + 5 - (static_cast(lines.size()) - 1) * 10; - for (const std::string& line : lines) { - pen.text(kInnerX, hintBaseline, line, 15, kTextMuted); - hintBaseline += 20; + case Screen::Eyewear: { + y += 10; + paragraph(t.contribEyewearTitle, 24, kText, true, 2); + y += 4; + paragraph(t.contribEyewearWhy, 16, kTextMuted, false, 2); + y += 20; + // The four answers, two to a row, the same weight (none is the default) + const double gap = 16; + const double bw = (width - gap) / 2; + const double bh = 64; + for (int i = 0; i < 4; ++i) { + const double bx = kInnerX + (i % 2) * (bw + gap); + const double by = y + (i / 2) * (bh + gap); + drawButton(pen, bx, by, bw, bh, t.contribEyewear[i], + {PanelAction::ContribEyewear, nullptr, static_cast(contrib::Eyewear::None) + i}, true, + false, 20); } + const double backW = std::max(140.0, pen.measure(t.contribBack, 20, true) + 48); + drawButton(pen, kInnerX, mainY, backW, mainH, t.contribBack, {PanelAction::ContribAgree, nullptr, 0}, true, + false, 20); break; } case Screen::Ready: { - stepTitle(); - if (contrib::isFree(step)) { - for (const char* tip : t.contribFreeTips) { - drawDot(pen.cr, kInnerX + 7, y + 13, 4, kAccent); - paragraph(tip, 19, kText, false, 2, kInnerX + 22); - y += 4; - } - y += 4; - paragraph(t.contribFreeEnd, 16, kTextMuted, false, 1); - } else { - paragraph(t.contribGuidedNote, 20, kText, false, 3); + paragraph(t.contribReadyTitle, 24, kText, true, 1); + y += 6; + for (const char* tip : t.contribTips) { + drawDot(pen.cr, kInnerX + 7, y + 13, 4, kAccent); + paragraph(tip, 18, kText, false, 2, kInnerX + 22); + y += 3; } - whyNot(step); - double startX = mainButton(contrib::isFree(step) ? t.contribFreeStart : t.contribGuidedStart, - {PanelAction::ContribStart, nullptr, step}, - contrib::startAllowed(c, m.eyecam, step), true); - // (the optional step: back to the end) - if (step >= contrib::kRequiredSteps) { - const double backW = std::max(140.0, pen.measure(t.contribBack, 20, true) + 48); - startX -= 14 + backW; - drawButton(pen, startX, mainY, backW, mainH, t.contribBack, {PanelAction::ContribExtra, nullptr, 0}, - true, false, 20); + if (count > 0) { + y += 6; + paragraph(countLine(), 16, kTextMuted, false, 1); } + startState(); + mainButton(t.contribStart, {PanelAction::ContribStart, nullptr, 0}, contrib::startAllowed(c, m.eyecam), + true); break; } case Screen::Failed: { - stepTitle(); paragraph(t.contribFailedTitle, 26, kDanger, true, 1); y += 6; const contrib::Failure& f = c.failure; @@ -7265,71 +7209,80 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) case contrib::Why::NoStart: why = t.contribWhyNoStart; break; case contrib::Why::Missing: why = t.contribWhyMissing; break; case contrib::Why::Move: why = formatLong(t.contribWhyMoveFormat, message); break; + case contrib::Why::Incomplete: why = formatLong(t.contribWhyIncompleteFormat, message); break; } paragraph(why, 19, kText, false, 3); if (!f.kept.empty()) { y += 4; paragraph(t.contribKept, 16, kTextMuted, false, 2); } - whyNot(step); - mainButton(t.contribRetry, {PanelAction::ContribStart, nullptr, step}, - contrib::startAllowed(c, m.eyecam, step) && - (!contrib::needsRewear(step) || !c.info.nextWear.empty()), + startState(); + mainButton(t.contribRetry, {PanelAction::ContribStart, nullptr, 0}, contrib::startAllowed(c, m.eyecam), true); break; } case Screen::Done: { - drawCheck(pen.cr, kInnerX + 14, y + 16, 24, kSuccess); - pen.text(kInnerX + 38, y + 26, t.contribDoneTitle, - fitSize(pen, t.contribDoneTitle, 26, 18, width - 38, true), kText, true); - y += 46; - paragraph(formatLong(t.contribTotalFormat, contrib::sizeText(contrib::recordedBytes(c.info))), 17, kText, - true, 1); + const bool done = contribPage_ == contrib::Page::Record; + if (done) { + drawCheck(pen.cr, kInnerX + 14, y + 16, 24, kSuccess); + pen.text(kInnerX + 38, y + 26, t.contribDoneTitle, + fitSize(pen, t.contribDoneTitle, 26, 18, width - 38, true), kText, true); + } else { + pen.text(kInnerX, y + 26, t.contribManageTitle, 26, kText, true); + } + y += 44; + paragraph(countLine(), 17, kText, true, 1); pen.text(kInnerX, y + 15, ellipsize(pen, formatLong(t.contribWhereFormat, recorder::shortPath(c.dir) + "/"), 15, false, width, true), 15, kTextMuted); y += 30; - paragraph(t.contribNext, 18, kText, false, 2); - y += 10; - // "Make one file", and beside it how that stands + if (done) { + paragraph(t.contribAgain, 16, kText, false, 2); + y += 4; + } + sendNote(15); + y += 12; + // "Make one file", and beside it how that stands (again once there are more recordings than it holds) const contrib::Progress& p = c.progress; const bool packing = p.running && p.job == contrib::Job::Pack; const long long packNeed = contrib::packNeedBytes(c.info, c.compress); const bool room = c.freeBytes < 0 || c.freeBytes >= packNeed; + const bool packedAll = !c.info.packed.empty() && c.info.packedCount >= count; const double packW = std::max(280.0, pen.measure(t.contribPack, 19, true) + 56); const double packH = 54; drawButton(pen, kInnerX, y, packW, packH, t.contribPack, {PanelAction::ContribPack, nullptr, 0}, - !jobRunning && room && c.info.packed.empty(), true, 19); + !jobRunning && room && count > 0 && !packedAll && !c.running, true, 19); const double sx = kInnerX + packW + 18; const double sw = kInnerRight - sx; std::string state; Color color = kTextMuted; bool bold = false; if (packing) { - const double done = p.total > 0 ? std::clamp(static_cast(p.done) / p.total, 0.0, 1.0) : 0.0; + const double share = p.total > 0 ? std::clamp(static_cast(p.done) / p.total, 0.0, 1.0) : 0.0; char text[128]; - std::snprintf(text, sizeof(text), t.contribPackingFormat, static_cast(done * 100)); + std::snprintf(text, sizeof(text), t.contribPackingFormat, static_cast(share * 100)); state = text; color = kText; // (a bar under the line) const double barY = y + packH - 16; fillRounded(pen, sx, barY, sw, 12, 6, kControl); - if (done > 0) fillRounded(pen, sx, barY, std::max(12.0, sw * done), 12, 6, kAccent); - } else if (!c.info.packed.empty()) { - // (done, and under it the file's name, cut at its start if it must be) - const std::string done = formatLong(t.contribPackedFormat, contrib::sizeText(c.info.packedBytes)); - pen.text(sx, y + packH / 2 - 4, done, 16, kSuccess, true); - pen.text(sx, y + packH / 2 + 18, ellipsize(pen, c.info.packed, 15, false, sw, true), 15, kText); + if (share > 0) fillRounded(pen, sx, barY, std::max(12.0, sw * share), 12, 6, kAccent); } else if (p.job == contrib::Job::Pack && p.finished && !p.ok) { state = formatLong(t.contribPackFailedFormat, p.error); color = kDanger; bold = true; + } else if (packedAll) { + // (done, and under it the file's name, cut at its start if it must be) + const std::string packed = formatLong(t.contribPackedFormat, std::to_string(c.info.packedCount), + contrib::sizeText(c.info.packedBytes)); + pen.text(sx, y + packH / 2 - 4, packed, 16, kSuccess, true); + pen.text(sx, y + packH / 2 + 18, ellipsize(pen, c.info.packed, 15, false, sw, true), 15, kText); } else if (!room) { state = formatLong(t.contribPackNoSpaceFormat, contrib::sizeText(packNeed)); color = kDanger; bold = true; - } else { + } else if (count > 0) { state = formatLong(t.contribPackHintFormat, contrib::sizeText(packNeed)); } const std::vector lines = wrapText(pen, state, 15, bold, sw, packing ? 1 : 2); @@ -7338,15 +7291,20 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) pen.text(sx, baseline, line, 15, color, bold); baseline += 20; } - // At the bottom: deleting them (after sending), and the optional free use while it isn't done - const double deleteW = std::max(220.0, pen.measure(t.contribDelete, 18, true) + 48); - drawButton(pen, kInnerX, bottom - 52, deleteW, 52, t.contribDelete, - {PanelAction::ContribDelete, nullptr, 0}, !jobRunning, false, 18); - if (c.info.steps[contrib::kRequiredSteps].rec.empty()) { - const double extraW = std::max(300.0, pen.measure(t.contribExtra, 18, true) + 48); - drawButton(pen, kInnerRight - extraW, bottom - 52, extraW, 52, t.contribExtra, - {PanelAction::ContribExtra, nullptr, 1}, !jobRunning, false, 18); + y += packH + 8; + // Recordings the pack left out (a file missing): to record again + if (!c.incomplete.empty()) { + std::string names; + for (const std::string& name : c.incomplete) names += (names.empty() ? "" : ", ") + name; + paragraph(formatLong(t.contribIncompleteFormat, names), 15, kDanger, true, 2); } + // At the bottom: deleting them (after sending), and "Close" + const double deleteW = std::max(200.0, pen.measure(t.contribDelete, 18, true) + 48); + drawButton(pen, kInnerX, bottom - 52, deleteW, 52, t.contribDelete, + {PanelAction::ContribDelete, nullptr, 0}, !jobRunning && !c.running && c.exists, false, 18); + const double closeW = std::max(160.0, pen.measure(t.contribClose, 18, true) + 48); + drawButton(pen, kInnerRight - closeW, bottom - 52, closeW, 52, t.contribClose, + {PanelAction::ContribClose, nullptr, 0}, true, false, 18); break; } case Screen::Running: @@ -7378,7 +7336,7 @@ void EyePanel::drawContribDelete(const Pen& pen, const UiText& t, const PanelMod const double yesW = std::max(160.0, pen.measure(t.contribDeleteYes, 18, true) + 56); const double yesX = ix + innerW - yesW; drawButton(pen, yesX, y, yesW, kControlH, t.contribDeleteYes, {PanelAction::ContribDeleteConfirm, nullptr, 0}, - !c.progress.running && c.running < 0, false, 18); + !c.progress.running && !c.running, false, 18); const double noW = std::max(140.0, pen.measure(t.contribCancel, 18, true) + 48); drawButton(pen, yesX - 12 - noW, y, noW, kControlH, t.contribCancel, {PanelAction::ContribDeleteCancel, nullptr, 0}, true, true, 18); diff --git a/panel/src/panel.h b/panel/src/panel.h index 3d825f6..1ea66f3 100644 --- a/panel/src/panel.h +++ b/panel/src/panel.h @@ -129,17 +129,16 @@ enum class PanelAction { PreviewMirror, ///< show the eyes as in a mirror, or as seen from the front (handled inside) PreviewCamera, ///< the eye cameras' pictures on / off (handled inside; the caller follows previewPictures) PreviewFreeze, ///< stop the drawing where it is, or let it move again (handled inside) - ContribOpen, ///< Debug tools' "Help train the AI": open its sheet (the panel opens it; the caller reads the - ///< contribution again) - ContribClose, ///< its cross, or "Cancel" on its first screen (handled inside) - ContribConsent, ///< "I'll help": the caller makes the contribution's folder - ContribRewore, ///< "I put it back on": the caller counts the next step's re-wear as done - ContribStart, ///< start step arg's recording (the caller sends its start to eyecam-rec) - ContribExtra, ///< arg 1: show the optional free use's step, 0: back to the end (handled inside) + ContribOpen, ///< Debug tools' "Record for squint training": open its sheet, arg = contrib::Page (the panel + ///< opens it; the caller forgets the last recording's end and failure) + ContribClose, ///< its cross, "Cancel" or "Close" (handled inside) + ContribAgree, ///< arg 1: "Agree and continue" (on to the question about glasses), 0: back (handled inside) + ContribEyewear, ///< an answer about glasses: arg = contrib::Eyewear (the caller writes the consent) + ContribStart, ///< "Start recording" (the caller sends its start to eyecam-rec) ContribPack, ///< "Make one file": the caller packs it on its worker - ContribDelete, ///< "Delete these recordings": its confirmation (handled inside) + ContribDelete, ///< "Delete the recordings": its confirmation (handled inside) ContribDeleteCancel, ///< ...closed without deleting (handled inside) - ContribDeleteConfirm, ///< ...confirmed: the caller deletes the contribution's folder + ContribDeleteConfirm, ///< ...confirmed: the caller deletes the folder }; /** A button: its action, the config key it changes and an argument. */ @@ -387,31 +386,35 @@ class EyePanel { bool previewOpen() const { return previewOpen_; } /** - * Open "Help train the AI" on the Advanced tab, in place of its page (the tabs and the sub-tabs' presses close it), - * as its row's button does. + * Open "Record for squint training" on the Advanced tab, in place of its page (the tabs and the sub-tabs' presses + * close it), as its row's buttons do. + * @param page "Record" or "Recordings" */ - void openContrib(); + void openContrib(contrib::Page page); /** Close it (and its confirmation). */ void closeContrib() { contribOpen_ = false; contribConfirm_ = false; - contribExtra_ = false; + contribAgreed_ = false; } /** @return true while it shows */ bool contribOpen() const { return tab_ == PanelTab::Advanced && contribOpen_; } - /** @return true while it shows the optional free use's step ("Record normal use once more") */ - bool contribExtra() const { return contribExtra_; } + /** @return how it was opened */ + contrib::Page contribPage() const { return contribPage_; } + + /** @return true once "Agree and continue" was pressed (the question about glasses shows) */ + bool contribAgreed() const { return contribAgreed_; } /** - * Show the optional free use's step, or the end again (for --fake-contrib). - * @param extra the optional step + * As if "Agree and continue" was pressed (for --fake-contrib). + * @param agreed pressed */ - void setContribExtra(bool extra) { contribExtra_ = extra; } + void setContribAgreed(bool agreed) { contribAgreed_ = agreed; } - /** Open the confirmation before deleting it (for --fake-contrib). */ + /** Open the confirmation before deleting (for --fake-contrib). */ void openContribDelete() { contribConfirm_ = true; } /** @return true while it wants the eye cameras' pictures (open, with its "Camera picture" on) */ @@ -748,9 +751,10 @@ class EyePanel { bool previewCamera_ = false; ///< ...with the eye cameras' pictures (off whenever the cameras can't give them) bool previewCameraKept_ = false; ///< ...the pictures' switch as last pressed, for the next time it opens (this ///< run of the panel only; the cameras going away turns it off too) - bool contribOpen_ = false; ///< "Help train the AI" is shown on the Advanced tab - bool contribExtra_ = false; ///< ...on the optional free use's step - bool contribConfirm_ = false; ///< ...with the confirmation before deleting it + bool contribOpen_ = false; ///< "Record for squint training" is shown on the Advanced tab + contrib::Page contribPage_ = contrib::Page::Record; ///< ...opened as "Record" or "Recordings" + bool contribAgreed_ = false; ///< ..."Agree and continue" pressed (the question about glasses) + bool contribConfirm_ = false; ///< ...with the confirmation before deleting bool previewFrozen_ = false; ///< ...stopped where it was bool previewFreezePending_ = false; ///< ...the values to keep are taken at the next draw eye_preview::View previewKept_; ///< ...the values and pictures it stopped at @@ -1398,17 +1402,14 @@ class EyePanel { * @param pen drawing tools * @param t texts * @param model the model (its eyecam view) - * @param title the section's title instead of the recording's ("" = its own; a step of "Help train the AI") - * @param instruction a recording's instruction instead of its step's, without the step chips (nullptr = its own; - * the free use) + * @param title the section's title instead of the recording's ("" = its own; "Record for squint training") */ - void drawRun(const Pen& pen, const UiText& t, const PanelModel& model, const std::string& title = "", - const char* instruction = nullptr); + void drawRun(const Pen& pen, const UiText& t, const PanelModel& model, const std::string& title = ""); /** - * "Help train the AI" (contrib::screen): the consent, the re-wear, a step's start, its recording (drawRun, titled - * with the step), a try that didn't finish, the end (pack, the optional free use, delete), and the confirmation - * before deleting. The title and the cross at the top, the steps as chips under it. + * "Record for squint training" (contrib::screen): the consent, the question about glasses, the start with what to + * do, the recording (drawRun, titled with it), a try that didn't finish, the end (how many, the folder, how to + * send it, pack, delete), and the confirmation before deleting. The title and the cross at the top. * @param pen drawing tools * @param t texts * @param model the model (its contribution and eyecam view) @@ -1424,7 +1425,8 @@ class EyePanel { void drawContribDelete(const Pen& pen, const UiText& t, const PanelModel& model); /** - * Debug tools' "Help train the AI" row: its button (only once the eye cameras are set up), and how far it is. + * Debug tools' "Record for squint training" row: "Record" and "Recordings" (only once the eye cameras are set up), + * and how many so far. * @param pen drawing tools * @param t texts * @param model the model diff --git a/panel/tests/contrib_test.cpp b/panel/tests/contrib_test.cpp index ce6dce0..4314c24 100644 --- a/panel/tests/contrib_test.cpp +++ b/panel/tests/contrib_test.cpp @@ -1,11 +1,12 @@ -// Tests for "Help train the AI" (contrib.cpp): the steps and their sizes (against eyecam's own protocol files), -// info.txt (written, read back, odd lines), the folder names, following a step's recording in eyecam-rec's status -// (done, stopped while searching or recording, failed, refused, never started, eyecam-rec gone, a panel restarted in -// the middle), the re-wear between steps (the eye video, the proximity sensor, a sensor that never reads worn, the -// Frame asleep, the fallback's delay), which screen shows and when a start can be pressed, the tar headers, and on real -// folders in a temporary one (never ~/eyecam): making a contribution, moving recordings into it, packing it (tar, and -// through zstd when it is there: unpacked again with the system's tar and compared) and deleting it, and a whole -// session from the consent to the end. Built with the panel as contrib-test; exits non-zero on failure. +// Tests for "Record for squint training" (contrib.cpp): a recording's size and when it fits, eyecam's +// protocol_contrib.txt (its length, and a text for each of its steps in both languages), info.txt (its sections +// written and read back, the last consent counting, odd lines), whether the headset was taken off between two +// recordings, following a recording in eyecam-rec's status (done, stopped while searching or recording, failed, +// refused, never started, eyecam-rec gone), which screen shows and when "Start" can be pressed, the tar headers, and on +// real folders in a temporary one (never ~/eyecam): the files a recording needs, moving it, the calibration result, +// packing (tar, and through zstd when it is there: unpacked again with the system's tar and compared; a recording +// lacking a file left out) and deleting, and a whole session from the consent on. Built with the panel as +// contrib-test; exits non-zero on failure. #include "contrib.h" #include @@ -59,6 +60,7 @@ void same(const std::string& got, const std::string& expected, int line) { #define SAME(got, expected) same((got), (expected), __LINE__) using contrib::Outcome; +using contrib::Page; using contrib::Screen; using eyecam::State; @@ -106,6 +108,15 @@ std::string readFile(const std::string& path) { return text.str(); } +/** + * Whether a path is there. + * @param path the path + * @return true if it is + */ +bool there(const std::string& path) { + return ::access(path.c_str(), F_OK) == 0; +} + /** * A status of eyecam-rec, fresh at a time. * @param state its state @@ -133,17 +144,29 @@ eyecam::Status status(State state, const std::string& protocol, double now) { } /** - * eyecam-rec set up and idle, as the panel would have it. + * eyecam-rec set up and idle, as the panel would have it, with its count of taking-offs. * @param now when its status was written + * @param offCount headset_off_count + * @param started started_unix * @return the view */ -eyecam::View eyecamReady(double now) { +eyecam::View eyecamReady(double now, long long offCount = 0, double started = kNow - 3600) { eyecam::View v; v.status = status(State::Idle, "", now); v.status.hasSetupDone = v.status.setupDone = true; v.status.hasBuffers = true; v.status.autoGrab = "ok"; v.status.calibState = eyecam::kCalibWearBit; + v.status.hasLidVeto = true; + v.status.lidVeto = true; + v.status.hasWidenSensitivity = true; + v.status.widenSensitivity = 0.6; + v.status.hasHeadsetOff = true; + v.status.headsetOffCount = offCount; + v.status.startedUnix = started; + v.status.hasCamFps = true; + v.status.camFps[0] = 90.0; + v.status.camFps[1] = 89.9; v.visible = true; v.password = eyecam::PasswordState::Set; return v; @@ -174,172 +197,212 @@ double protocolSeconds(const std::string& text) { return total; } -void testSteps() { +void testSizes() { using namespace contrib; - SAME(protocolOf(0), kGuidedProtocol); - SAME(protocolOf(2), kGuidedProtocol); - SAME(protocolOf(3), kFreeProtocol); - SAME(protocolOf(4), kFreeProtocol); - CHECK(!isFree(2) && isFree(3) && isFree(4)); - CHECK(!needsRewear(0) && needsRewear(1) && needsRewear(3) && needsRewear(4)); - // eyecam-rec's estimate: 60.5 s and 181.5 s of two 90 fps cameras at 160300 bytes a frame - CHECK(estimateBytes(0, 90) == static_cast(60.5 * 2 * 90 * 160300.0)); - CHECK(estimateBytes(3, 90) == static_cast(181.5 * 2 * 90 * 160300.0)); - CHECK(estimateBytes(0, NAN) == estimateBytes(0, 90)); - CHECK(estimateBytes(0, 0) == estimateBytes(0, 90)); - CHECK(estimateBytes(0, 120) > estimateBytes(0, 90)); - // About 1.75 GB a guided one, 5.2 GB the free use: 10.5 GB for the four - Info info; - const long long all = neededBytes(info, 90) - kMarginBytes; - CHECK(all > 10'400'000'000LL && all < 10'600'000'000LL); - SAME(sizeText(all), "10.5 GB"); + // eyecam-rec's estimate: 94.5 s of two 90 fps cameras at 160300 bytes a frame, about 2.7 GB + CHECK(estimateBytes(90) == static_cast(94.5 * 2 * 90 * 160300.0)); + SAME(sizeText(estimateBytes(90)), "2.7 GB"); SAME(sizeText(523'000'000), "523 MB"); - // The order: the first not done; the optional one only when asked for, after the others - CHECK(nextStep(info, false) == 0); - CHECK(nextStep(info, true) == 0); - info.steps[0].rec = "rec_2026-10-25_14-02-41"; - info.steps[1].rec = "rec_2026-10-25_14-05-12"; - CHECK(nextStep(info, false) == 2); - CHECK(requiredDone(info) == 2); - CHECK(neededBytes(info, 90) == kMarginBytes + estimateBytes(2, 90) + estimateBytes(3, 90)); - info.steps[2].rec = "rec_2026-10-25_14-08-03"; - info.steps[3].rec = "rec_2026-10-25_14-11-40"; - CHECK(nextStep(info, false) == kStepCount); - CHECK(nextStep(info, true) == 4); - CHECK(requiredDone(info) == kRequiredSteps); - CHECK(neededBytes(info, 90) == kMarginBytes); - info.steps[4].rec = "rec_2026-10-25_14-20-00"; - CHECK(nextStep(info, true) == kStepCount); - // Sizes: what was recorded, what packing needs - for (Step& step : info.steps) step.bytes = 1'000'000'000; - CHECK(recordedBytes(info) == 5'000'000'000LL); - CHECK(packNeedBytes(info, true) == 3'750'000'000LL + kMarginBytes); - CHECK(packNeedBytes(info, false) == 5'000'000'000LL + kMarginBytes); - // A step fits with its estimate and the margin free (or the free space unknown) - CHECK(fits(0, estimateBytes(0, 90) + kMarginBytes, 90)); - CHECK(!fits(0, estimateBytes(0, 90) + kMarginBytes - 1, 90)); - CHECK(!fits(3, estimateBytes(0, 90) + kMarginBytes, 90)); - CHECK(fits(3, -1, 90)); + CHECK(estimateBytes(NAN) == estimateBytes(90)); + CHECK(estimateBytes(0) == estimateBytes(90)); + CHECK(estimateBytes(120) > estimateBytes(90)); + // It fits with its estimate and the margin free (or the free space unknown) + CHECK(fits(estimateBytes(90) + kMarginBytes, 90)); + CHECK(!fits(estimateBytes(90) + kMarginBytes - 1, 90)); + CHECK(!fits(estimateBytes(90) + kMarginBytes, 120)); + CHECK(fits(-1, 90)); + // What packing needs: 0.7 of the recordings through zstd, all of them without, and the margin + Info info; + info.recordings.resize(2); + info.recordings[0].bytes = info.recordings[1].bytes = 1'000'000'000; + CHECK(recordedBytes(info) == 2'000'000'000LL); + CHECK(packNeedBytes(info, true) == 1'400'000'000LL + kMarginBytes); + CHECK(packNeedBytes(info, false) == 2'000'000'000LL + kMarginBytes); + // The consent: none, an older text's, or no answer about glasses yet asks again + Info consent; + CHECK(consentNeeded(consent)); + consent.consentVersion = kConsentVersion; + CHECK(consentNeeded(consent)); + consent.eyewear = Eyewear::NoAnswer; + CHECK(!consentNeeded(consent)); + consent.consentVersion = kConsentVersion - 1; + CHECK(consentNeeded(consent)); + for (const Eyewear e : {Eyewear::None, Eyewear::Glasses, Eyewear::Inserts, Eyewear::NoAnswer}) { + CHECK(parseEyewear(eyewearName(e)) == e); + } + CHECK(parseEyewear("contacts") == Eyewear::Unasked); } -void testProtocolFiles() { +void testProtocolFile() { using namespace contrib; - // The protocols as eyecam ships them (install.sh puts every protocol_*.txt beside eyecam-rec) - const std::string guided = readFile(std::string(EYECAM_SOURCE_DIR) + "/protocol_" + kGuidedProtocol + ".txt"); - const std::string free = readFile(std::string(EYECAM_SOURCE_DIR) + "/protocol_" + kFreeProtocol + ".txt"); - CHECK(!guided.empty() && !free.empty()); - CHECK(protocolSeconds(guided) == kGuidedSec); - CHECK(protocolSeconds(free) == kFreeSec); - // Its steps are what the panel says: close / normal / widen, then squint, up, down; no light - const std::vector steps = eyecam::parseProtocol(guided); - CHECK(steps.size() == 14); - for (const char* label : {"close", "normal", "widen", "squint", "look_up", "look_down"}) { - CHECK(std::find(steps.begin(), steps.end(), std::string(label)) != steps.end()); - } + // The protocol as eyecam ships it (install.sh puts every protocol_*.txt beside eyecam-rec) + const std::string text = readFile(std::string(EYECAM_SOURCE_DIR) + "/protocol_" + kProtocol + ".txt"); + CHECK(!text.empty()); + CHECK(protocolSeconds(text) == kProtocolSec); + const std::vector steps = eyecam::parseProtocol(text); + CHECK(steps.size() == 23); + CHECK(std::count(steps.begin(), steps.end(), "close") == 3); + CHECK(std::count(steps.begin(), steps.end(), "squint") == 3); CHECK(std::find(steps.begin(), steps.end(), "bright") == steps.end()); - CHECK(std::find(steps.begin(), steps.end(), "dark") == steps.end()); - CHECK(eyecam::parseProtocol(free) == std::vector {"normal"}); + // Every step is one the panel knows, with its own text in both languages (left and right as the wearer sees) + for (const Language language : {Language::Ja, Language::En}) { + const UiText& t = uiText(language); + for (const std::string& label : steps) { + CHECK(eyecam::parseStep(label) != eyecam::Step::Unknown); + CHECK(eyecam::instruction(t, label) != label); + if (label != "lead_in") CHECK(eyecam::chipLabel(t, label) != label); + } + } + SAME(eyecam::instruction(uiText(Language::Ja), "look_left"), "左の耳の方を見て"); + SAME(eyecam::instruction(uiText(Language::En), "look_right"), "Look toward your right ear"); } void testInfo() { using namespace contrib; - Info info; - info.appVersion = "0.8.2-beta.2"; - info.consent = "2026-10-25 14:02:10 +0900"; - for (int step = 0; step < kStepCount; ++step) info.steps[step].protocol = protocolOf(step); - info.steps[0] = {kGuidedProtocol, "rec_2026-10-25_14-02-41", 1'745'000'000, kWearFirst, "2026-10-25 14:03:44"}; - info.steps[1] = {kGuidedProtocol, "rec_2026-10-25_14-05-12", 1'744'000'000, kWearButton, "2026-10-25 14:06:30"}; - info.nextWear = kWearDetected; - info.running = 2; - info.runningSince = 1'790'000'123.25; - info.aborted = {"aborted/rec_2026-10-25_14-04-00 stopped"}; - const std::string text = formatInfo(info); - // No personal data: the comment, the version, times, protocols, folders and sizes only + contrib::Recording r; + r.rec = "rec_2026-10-25_14-05-12"; + r.protocol = kProtocol; + r.start = "2026-10-25 14:03:40 +0900"; + r.startUnix = kNow; + r.endUnix = kNow + 97.25; + r.bytes = 2'712'000'000; + r.appVersion = "0.8.2-beta.2"; + r.eyecamRec = "294d06c6"; + r.lidVeto = "on"; + r.widen = "on"; + r.widenSensitivity = "0.60"; + r.cameraLids = "on"; + r.eyewear = "inserts"; + r.fps = 89.9; + r.sincePrevious = "12.5"; + r.headsetOff = "yes"; + r.offCountStart = 3; + r.offCountEnd = 3; + r.eyecamStarted = kNow - 3600; + r.calib = "rec_2026-10-25_14-05-12.calib_result.json"; + const std::string text = headerText() + consentSection("2026-10-25 14:02:10 +0900", 1, Eyewear::Inserts) + + recordingSection(r) + abortedSection("aborted/rec_2026-10-25_14-09-00", "stopped") + + packedSection("contrib_2026-10-25_14-25-02.tar.zst", 1, 1'600'000'000); + // No personal data: what it is, the format, times, settings, folder names and sizes only CHECK(text.rfind("# frameeyeosc", 0) == 0); - CHECK(text.find("app_version=0.8.2-beta.2\n") != std::string::npos); - CHECK(text.find("step1_rec=rec_2026-10-25_14-02-41\n") != std::string::npos); - CHECK(text.find("step2_wear=button\n") != std::string::npos); - CHECK(text.find("step4_protocol=free\n") != std::string::npos); - CHECK(text.find("step3_rec=") == std::string::npos); - CHECK(text.find("running=3\n") != std::string::npos); - Info back; - CHECK(parseInfo(text, back)); - SAME(back.appVersion, info.appVersion); - SAME(back.consent, info.consent); - for (int step = 0; step < kStepCount; ++step) { - SAME(back.steps[step].protocol, protocolOf(step)); - SAME(back.steps[step].rec, info.steps[step].rec); - CHECK(back.steps[step].bytes == info.steps[step].bytes); - SAME(back.steps[step].wear, info.steps[step].wear); - SAME(back.steps[step].done, info.steps[step].done); - } - SAME(back.nextWear, kWearDetected); - CHECK(back.running == 2); - CHECK(std::fabs(back.runningSince - info.runningSince) < 1e-3); - CHECK(back.aborted == info.aborted); - CHECK(back.packed.empty()); - // Packed, nothing running - info.running = -1; - info.packed = "contrib_2026-10-25_14-02-10.tar.zst"; - info.packedBytes = 2'100'000'000; - CHECK(parseInfo(formatInfo(info), back)); - CHECK(back.running == -1); - SAME(back.packed, info.packed); - CHECK(back.packedBytes == info.packedBytes); - // Odd lines are skipped, CRLF read, a recording's name that isn't one dropped (that step is done again), steps - // past the fifth ignored - CHECK(parseInfo("consent=x\r\nnonsense\nstep1_rec=../../etc\nstep2_rec=rec_2026-10-25_14-05-12\r\n" - "step9_rec=rec_2026-10-25_14-05-12\nrunning=9\nstep2_bytes=12x\n", - back)); - CHECK(back.steps[0].rec.empty()); - SAME(back.steps[1].rec, "rec_2026-10-25_14-05-12"); - CHECK(back.steps[1].bytes == 0); - CHECK(back.running == -1); - // Without the consent it isn't a contribution's - CHECK(!parseInfo("app_version=1\n", back)); - CHECK(!parseInfo("", back)); + CHECK(text.find("format=2\n") != std::string::npos); + CHECK(text.find("\n[consent]\ntime=2026-10-25 14:02:10 +0900\nversion=1\neyewear=inserts\n") != std::string::npos); + CHECK(text.find("\nheadset_off=yes\n") != std::string::npos); + CHECK(text.find("\nminutes_since_previous=12.5\n") != std::string::npos); + CHECK(text.find("\ncamera_fps=89.9\n") != std::string::npos); + CHECK(text.find("\neyecam_rec_sha256=294d06c6\n") != std::string::npos); + Info info; + parseInfo(text, info); + CHECK(info.consentVersion == 1 && info.eyewear == Eyewear::Inserts); + SAME(info.consent, "2026-10-25 14:02:10 +0900"); + CHECK(info.recordings.size() == 1); + if (!info.recordings.empty()) { + const contrib::Recording& back = info.recordings[0]; + SAME(back.rec, r.rec); + SAME(back.protocol, r.protocol); + SAME(back.start, r.start); + CHECK(std::fabs(back.startUnix - r.startUnix) < 1e-3 && std::fabs(back.endUnix - r.endUnix) < 1e-3); + CHECK(back.bytes == r.bytes); + SAME(back.appVersion, r.appVersion); + SAME(back.eyecamRec, r.eyecamRec); + SAME(back.lidVeto, "on"); + SAME(back.widen, "on"); + SAME(back.widenSensitivity, "0.60"); + SAME(back.cameraLids, "on"); + SAME(back.eyewear, "inserts"); + CHECK(std::fabs(back.fps - 89.9) < 1e-9); + SAME(back.sincePrevious, "12.5"); + SAME(back.headsetOff, "yes"); + CHECK(back.offCountStart == 3 && back.offCountEnd == 3); + CHECK(std::fabs(back.eyecamStarted - r.eyecamStarted) < 1e-3); + SAME(back.calib, r.calib); + } + CHECK(info.aborted == std::vector {"aborted/rec_2026-10-25_14-09-00 stopped"}); + SAME(info.packed, "contrib_2026-10-25_14-25-02.tar.zst"); + CHECK(info.packedCount == 1 && info.packedBytes == 1'600'000'000); + // Unknowns as they are written + contrib::Recording unknown; + unknown.rec = "rec_2026-10-25_14-20-55"; + const std::string second = recordingSection(unknown); + CHECK(second.find("\neyecam_rec_sha256=unknown\n") != std::string::npos); + CHECK(second.find("\ncamera_fps=unknown\n") != std::string::npos); + CHECK(second.find("\neyecam_started_unix=unknown\n") != std::string::npos); + CHECK(second.find("\nheadset_off_count_start=-1\n") != std::string::npos); + // Sections added later: the last consent counts (an older text's asks again), recordings add up + parseInfo(text + recordingSection(unknown) + consentSection("2026-11-01 10:00:00 +0900", 0, Eyewear::None), info); + CHECK(info.recordings.size() == 2 && info.consentVersion == 0 && info.eyewear == Eyewear::None); + CHECK(consentNeeded(info)); + if (info.recordings.size() == 2) CHECK(info.recordings[1].offCountStart == -1 && info.recordings[1].eyecamRec.empty()); + // Odd lines are skipped, CRLF read, a recording's name that isn't one dropped, a pack's that isn't one forgotten + parseInfo("format=2\r\n[consent]\r\nversion=1\r\neyewear=glasses\r\nnonsense\r\n[recording]\r\nrec=../../etc\r\n" + "[recording]\nrec=rec_2026-10-25_14-05-12\nbytes=12x\n[packed]\nfile=/etc/passwd\n[mystery]\nrec=x\n", + info); + CHECK(info.consentVersion == 1 && info.eyewear == Eyewear::Glasses); + CHECK(info.recordings.size() == 1); + if (!info.recordings.empty()) CHECK(info.recordings[0].bytes == 0); + CHECK(info.packed.empty()); + parseInfo("", info); + CHECK(consentNeeded(info) && info.recordings.empty()); } void testNames() { using namespace contrib; - CHECK(isFolderName("contrib_2026-10-25_14-02-10")); - CHECK(!isFolderName("contrib_2026-10-25_14-02-10.tar.zst")); - CHECK(!isFolderName("contrib_2026-10-25")); - CHECK(!isFolderName("rec_2026-10-25_14-02-10")); - CHECK(!isFolderName("contrib_../../x")); CHECK(isRecordingName("rec_2026-10-25_14-02-10")); CHECK(!isRecordingName("calib_2026-10-25_14-02-10")); CHECK(!isRecordingName("rec_fake")); + CHECK(!isRecordingName("rec_2026-10-25_14-02-10x")); CHECK(!isRecordingName("aborted/rec_2026-10-25_14-02-10")); - const std::string name = folderName(static_cast(kNow)); - CHECK(isFolderName(name)); + CHECK(isPackName("contrib_2026-10-25_14-25-02.tar.zst")); + CHECK(isPackName("contrib_2026-10-25_14-25-02.tar")); + CHECK(!isPackName("contrib_2026-10-25_14-25-02.zip")); + CHECK(!isPackName("contrib_2026-10-25.tar")); + CHECK(!isPackName("../contrib_2026-10-25_14-25-02.tar")); + CHECK(isPackName(packName(static_cast(kNow), true))); + CHECK(packName(static_cast(kNow), false).size() == 31); const std::string local = localTime(static_cast(kNow), true); CHECK(local.size() == 25 && local[10] == ' ' && (local[20] == '+' || local[20] == '-')); CHECK(localTime(static_cast(kNow), false).size() == 19); } +void testHeadsetOff() { + contrib::Recording before; + before.offCountEnd = 2; + before.eyecamStarted = kNow - 3600; + // Within one run of eyecam-rec: the count went up, or it didn't + SAME(contrib::headsetOffBetween(before, 3, kNow - 3600), "yes"); + SAME(contrib::headsetOffBetween(before, 2, kNow - 3600.4), "no"); + // eyecam-rec restarted between (its count starts again), or a count not known: unknown + SAME(contrib::headsetOffBetween(before, 5, kNow - 60), "unknown"); + SAME(contrib::headsetOffBetween(before, -1, kNow - 3600), "unknown"); + before.offCountEnd = -1; + SAME(contrib::headsetOffBetween(before, 2, kNow - 3600), "unknown"); + before.offCountEnd = 2; + before.eyecamStarted = 0; + SAME(contrib::headsetOffBetween(before, 2, kNow - 3600), "unknown"); +} + void testRunWatch() { using contrib::RunWatch; - const std::string p = contrib::kGuidedProtocol; + const std::string p = contrib::kProtocol; const std::string dir = "/home/steamos/eyecam/rec_2026-10-25_14-05-12"; // Searching, recording, then idle with its folder: done { RunWatch w; - w.begin(1, p, kNow); - CHECK(w.active() && w.step() == 1); + w.begin(p, kNow); + CHECK(w.active()); CHECK(w.follow(status(State::Searching, p, kNow + 0.1), true, kNow + 0.1, NAN).outcome == Outcome::Running); CHECK(w.follow(status(State::Recording, p, kNow + 30), true, kNow + 30, NAN).outcome == Outcome::Running); - eyecam::Status s = status(State::Idle, p, kNow + 63); + eyecam::Status s = status(State::Idle, p, kNow + 97); s.sessionDir = dir; s.message = "保存した: rec_2026-10-25_14-05-12"; - const contrib::RunEnd end = w.follow(s, true, kNow + 63, kNow + 2); + const contrib::RunEnd end = w.follow(s, true, kNow + 97, kNow + 2); CHECK(end.outcome == Outcome::Done); SAME(end.dir, dir); } // ...ended early (Stop, or the eyes lost): stopped, with the part's folder { RunWatch w; - w.begin(1, p, kNow); + w.begin(p, kNow); w.follow(status(State::Recording, p, kNow + 10), true, kNow + 10, NAN); eyecam::Status s = status(State::Idle, p, kNow + 20); s.sessionDir = dir; @@ -352,7 +415,7 @@ void testRunWatch() { // ...stopped while it searched: no folder (eyecam-rec cleared session_dir when it started) { RunWatch w; - w.begin(1, p, kNow); + w.begin(p, kNow); w.follow(status(State::Searching, p, kNow + 1), true, kNow + 1, NAN); const contrib::RunEnd end = w.follow(status(State::Idle, p, kNow + 5), true, kNow + 5, NAN); CHECK(end.outcome == Outcome::Stopped); @@ -361,7 +424,7 @@ void testRunWatch() { // ...failed: eyecam-rec's error, with what it saved { RunWatch w; - w.begin(1, p, kNow); + w.begin(p, kNow); w.follow(status(State::Recording, p, kNow + 10), true, kNow + 10, NAN); eyecam::Status s = status(State::Error, p, kNow + 20); s.sessionDir = dir; @@ -377,7 +440,7 @@ void testRunWatch() { // An older status (idle, the previous recording's folder) right after the start: not this one's end { RunWatch w; - w.begin(2, p, kNow); + w.begin(p, kNow); eyecam::Status s = status(State::Idle, p, kNow + 0.05); s.sessionDir = "/home/steamos/eyecam/rec_2026-10-25_14-02-41"; CHECK(w.follow(s, true, kNow + 0.05, kNow - 120).outcome == Outcome::Running); @@ -390,7 +453,7 @@ void testRunWatch() { // A start that failed at once (error before the panel saw it searching): its message, after the grace { RunWatch w; - w.begin(0, p, kNow); + w.begin(p, kNow); eyecam::Status s = status(State::Error, p, kNow + 0.2); s.message = "ディスクの空きが 1 GB 未満"; CHECK(w.follow(s, true, kNow + 0.2, NAN).outcome == Outcome::Running); @@ -402,40 +465,39 @@ void testRunWatch() { // A calibration or another recording running: not this one (it never started) { RunWatch w; - w.begin(0, p, kNow); + w.begin(p, kNow); CHECK(w.follow(status(State::Recording, "widen", kNow + 1), true, kNow + 1, NAN).outcome == Outcome::Running); CHECK(w.follow(status(State::Calibrating, "calib wear", kNow + 9), true, kNow + 9, NAN).outcome == Outcome::Failed); } - // The panel restarted in the middle: it ended meanwhile, its folder made after the start: done + // Missed while it ran (the loop slow): its folder made after the start says it ended; another protocol's doesn't { RunWatch w; - w.begin(3, contrib::kFreeProtocol, kNow); - eyecam::Status s = status(State::Idle, contrib::kFreeProtocol, kNow + 400); + w.begin(p, kNow); + eyecam::Status s = status(State::Idle, p, kNow + 400); s.sessionDir = dir; CHECK(w.follow(s, true, kNow + 400, kNow + 3).outcome == Outcome::Done); - // ...but another protocol's folder isn't it RunWatch other; - other.begin(3, contrib::kFreeProtocol, kNow); - eyecam::Status s2 = status(State::Idle, p, kNow + 400); + other.begin(p, kNow); + eyecam::Status s2 = status(State::Idle, "widen", kNow + 400); s2.sessionDir = dir; CHECK(other.follow(s2, true, kNow + 400, kNow + 3).outcome == Outcome::Failed); } // ...its folder gone (moved by hand): missing { RunWatch w; - w.begin(1, p, kNow); + w.begin(p, kNow); w.follow(status(State::Recording, p, kNow + 10), true, kNow + 10, NAN); - eyecam::Status s = status(State::Idle, p, kNow + 63); + eyecam::Status s = status(State::Idle, p, kNow + 97); s.sessionDir = dir; - const contrib::RunEnd end = w.follow(s, true, kNow + 63, NAN); + const contrib::RunEnd end = w.follow(s, true, kNow + 97, NAN); CHECK(end.outcome == Outcome::Failed); CHECK(end.why == contrib::Why::Missing); } // eyecam-rec gone (its status stale) for kGoneSec: failed, not before { RunWatch w; - w.begin(1, p, kNow); + w.begin(p, kNow); w.follow(status(State::Recording, p, kNow + 10), true, kNow + 10, NAN); const eyecam::Status stale = status(State::Recording, p, kNow + 10); CHECK(w.follow(stale, false, kNow + 16, NAN).outcome == Outcome::Running); @@ -452,158 +514,62 @@ void testRunWatch() { } } -void testWearWatch() { - using contrib::WearWatch; - const auto idle = [](bool locked, double prox = -1.0) { - eyecam::Status s = status(State::Idle, "", kNow); - s.locked = locked; - s.prox = prox; - return s; - }; - // The eye video stops for kOffSec, then comes back for kOnSec - { - WearWatch w; - w.start(100); - double t = 100; - for (; t < 101; t += 0.1) w.follow(idle(true), true, t); - for (double end = t + 2.9; t < end; t += 0.1) w.follow(idle(false), true, t); - CHECK(!w.offSeen()); - for (double end = t + 0.3; t < end; t += 0.1) w.follow(idle(false), true, t); - CHECK(w.offSeen() && !w.detected()); - for (double end = t + 1.8; t < end; t += 0.1) w.follow(idle(true), true, t); - CHECK(!w.detected()); - for (double end = t + 0.5; t < end; t += 0.1) w.follow(idle(true), true, t); - CHECK(w.detected()); - } - // A short gap in the video (a blink of the tracker) is no re-wear - { - WearWatch w; - w.start(0); - double t = 0; - for (; t < 20; t += 0.1) w.follow(idle(t < 5 || t > 6.5), true, t); - CHECK(!w.offSeen() && !w.detected()); - } - // The sensor: off after it read worn, then worn again (live processing off: the video doesn't say) - { - WearWatch w; - w.start(0); - double t = 0; - const auto noLive = [&](double prox) { - eyecam::Status s = idle(false, prox); - s.live = false; - return s; - }; - for (; t < 2; t += 0.1) w.follow(noLive(31), true, t); - for (; t < 6; t += 0.1) w.follow(noLive(9), true, t); - CHECK(w.offSeen()); - for (; t < 9; t += 0.1) w.follow(noLive(31), true, t); - CHECK(w.detected()); - } - // A face the sensor never reads worn on: its low reading alone is no "off" - { - WearWatch w; - w.start(0); - for (double t = 0; t < 30; t += 0.1) w.follow(idle(true, 12), true, t); - CHECK(!w.offSeen()); - } - // The Frame slept (the loop stood still kOffSec or more): off; on again with the video - { - WearWatch w; - w.start(0); - w.follow(idle(true), true, 1); - w.follow(idle(true), true, 1 + contrib::kOffSec + 30); - CHECK(w.offSeen()); - w.follow(idle(true), true, 40); - w.follow(idle(true), true, 42.5); - CHECK(w.detected()); - } - // Back on without being off first: nothing - { - WearWatch w; - w.start(0); - for (double t = 0; t < 30; t += 0.1) w.follow(idle(true, 30), true, t); - CHECK(!w.offSeen() && !w.detected()); - } - // eyecam-rec not fresh: its numbers don't count - { - WearWatch w; - w.start(0); - for (double t = 0; t < 10; t += 0.1) w.follow(idle(false), false, t); - CHECK(!w.offSeen()); - } - // "I put it back on": kFallbackSec after the start, and only while watching - { - WearWatch w; - CHECK(!w.fallbackReady(1000)); - w.start(100); - CHECK(!w.fallbackReady(100 + contrib::kFallbackSec - 0.5)); - CHECK(w.fallbackReady(100 + contrib::kFallbackSec)); - w.stop(); - CHECK(!w.fallbackReady(1000)); - // Started again: the time again, and what it saw forgotten - w.start(500); - CHECK(!w.fallbackReady(505) && !w.offSeen()); - } -} - void testScreen() { using namespace contrib; View v; const eyecam::View ready = eyecamReady(kNow); - CHECK(screen(v, false) == Screen::Consent); - CHECK(!startAllowed(v, ready, 0)); - v.dir = "/home/steamos/eyecam/contrib_2026-10-25_14-02-10"; + // The consent first (then the question about glasses), from "Record"; "Recordings" goes to the end's screen + CHECK(screen(v, Page::Record, false) == Screen::Consent); + CHECK(screen(v, Page::Record, true) == Screen::Eyewear); + CHECK(screen(v, Page::Manage, false) == Screen::Done); + CHECK(!startAllowed(v, ready)); + v.exists = true; + v.info.consentVersion = kConsentVersion; + v.info.eyewear = Eyewear::None; v.freeBytes = 20'000'000'000LL; - CHECK(screen(v, false) == Screen::Ready); - CHECK(startAllowed(v, ready, 0)); - // eyecam-rec busy, not set up, a command waiting, no room: no start + CHECK(screen(v, Page::Record, false) == Screen::Ready); + CHECK(screen(v, Page::Record, true) == Screen::Ready); + CHECK(startAllowed(v, ready)); + // eyecam-rec busy, not set up, a command waiting, no room, packing: no start { eyecam::View busy = ready; busy.status.state = State::Calibrating; - CHECK(!startAllowed(v, busy, 0)); + CHECK(!startAllowed(v, busy)); eyecam::View waiting = ready; waiting.busy = true; - CHECK(!startAllowed(v, waiting, 0)); + CHECK(!startAllowed(v, waiting)); eyecam::View notSetUp = ready; notSetUp.status.setupDone = false; notSetUp.status.calibState = 0; - CHECK(!startAllowed(v, notSetUp, 0)); + CHECK(!startAllowed(v, notSetUp)); eyecam::View error = ready; error.status.state = State::Error; - CHECK(startAllowed(v, error, 0)); + CHECK(startAllowed(v, error)); View full = v; - full.freeBytes = 2'000'000'000LL; - CHECK(!startAllowed(full, ready, 0)); + full.freeBytes = 3'000'000'000LL; + CHECK(!startAllowed(full, ready)); View packing = v; packing.progress.running = true; - CHECK(!startAllowed(packing, ready, 0)); - } - // After the first: the re-wear, then the start - v.info.steps[0].rec = "rec_2026-10-25_14-02-41"; - CHECK(screen(v, false) == Screen::Rewear); - v.info.nextWear = kWearDetected; - CHECK(screen(v, false) == Screen::Ready); - // Running - v.running = 1; - CHECK(screen(v, false) == Screen::Running); - CHECK(!startAllowed(v, ready, 1)); - v.running = -1; - // A try that didn't finish: its screen, for its step only - v.failure.step = 1; - CHECK(screen(v, false) == Screen::Failed); - v.failure.step = 0; - CHECK(screen(v, false) == Screen::Ready); - // All four: the end; the optional one when asked for (its re-wear first) - for (int step = 0; step < kRequiredSteps; ++step) v.info.steps[step].rec = "rec_2026-10-25_14-02-41"; - v.info.nextWear.clear(); - CHECK(screen(v, false) == Screen::Done); - CHECK(screen(v, true) == Screen::Rewear); - v.info.nextWear = kWearButton; - CHECK(screen(v, true) == Screen::Ready); + CHECK(!startAllowed(packing, ready)); + View older = v; + older.info.consentVersion = kConsentVersion - 1; + CHECK(!startAllowed(older, ready)); + CHECK(screen(older, Page::Record, false) == Screen::Consent); + } + // Running, then how it ended + v.running = true; + CHECK(screen(v, Page::Record, false) == Screen::Running); + CHECK(!startAllowed(v, ready)); + v.running = false; + v.failure.shown = true; + CHECK(screen(v, Page::Record, false) == Screen::Failed); + v.failure = Failure(); + v.justDone = true; + CHECK(screen(v, Page::Record, false) == Screen::Done); // Being deleted: that, whatever else v.progress.job = Job::Delete; v.progress.running = true; - CHECK(screen(v, false) == Screen::Deleting); + CHECK(screen(v, Page::Manage, false) == Screen::Deleting); } /** @@ -619,8 +585,7 @@ bool checksumOk(const std::string& header) { void testTarHeader() { using contrib::Entry; - Entry file {"/x/eye_L.raw", "contrib_2026-10-25_14-02-10/rec_2026-10-25_14-02-41/eye_L.raw", 1'000'320'000, - 1'790'000'000, false}; + Entry file {"/x/eye_L.raw", "contrib/rec_2026-10-25_14-02-41/eye_L.raw", 1'000'320'000, 1'790'000'000, false}; const std::string header = contrib::tarHeader(file); CHECK(header.size() == 512); SAME(header.substr(0, file.name.size()), file.name); @@ -632,7 +597,7 @@ void testTarHeader() { SAME(header.substr(100, 7), "0000644"); CHECK(checksumOk(header)); // A folder - const Entry folder {"/x", "contrib_2026-10-25_14-02-10/", 0, 1'790'000'000, true}; + const Entry folder {"/x", "contrib/", 0, 1'790'000'000, true}; const std::string dir = contrib::tarHeader(folder); CHECK(dir[156] == '5'); SAME(dir.substr(100, 7), "0000755"); @@ -661,19 +626,22 @@ void testTarHeader() { * @param name its name * @param bytes the size of its eye_L.raw (eye_R.raw is a little smaller) * @param made when it was made (its modification time; the tests' clock runs ahead of the real one) + * @param without a file it lacks ("" = none) * @return its path */ -std::string makeRecording(const std::string& home, const std::string& name, size_t bytes, double made = kNow) { +std::string makeRecording(const std::string& home, const std::string& name, size_t bytes, double made = kNow, + const std::string& without = "") { const std::string dir = home + "/" + name; ::mkdir(dir.c_str(), 0755); std::string video(bytes, '\0'); for (size_t i = 0; i < bytes; ++i) video[i] = static_cast((i * 7 + i / 400) & 0xff); writeFile(dir + "/eye_L.raw", video); writeFile(dir + "/eye_R.raw", video.substr(0, bytes - 13)); - writeFile(dir + "/cues.csv", "t,label\n0.0,lead_in\n"); - writeFile(dir + "/valve.csv", "t,openness_l\n"); - writeFile(dir + "/frames.csv", "eye,t_cam\n"); - writeFile(dir + "/meta.txt", "protocol=widen_nolight\n"); + for (const auto& [file, text] : {std::pair {"cues.csv", "t,label\n0.0,lead_in\n"}, + {"valve.csv", "t,openness_l\n"}, {"frames.csv", "eye,t_cam\n"}, + {"meta.txt", "protocol=contrib\n"}}) { + if (without != file) writeFile(dir + "/" + file, text); + } const timespec times[2] = {{static_cast(made), 0}, {static_cast(made), 0}}; ::utimensat(AT_FDCWD, dir.c_str(), times, 0); return dir; @@ -683,41 +651,31 @@ void testFiles() { using namespace contrib; const RemoveDir temp {tempDir()}; const std::string home = temp.path + "/eyecam"; - // None yet; a folder that isn't one is never taken + const std::string dir = home + "/" + kFolderName; ::mkdir(home.c_str(), 0755); - CHECK(findCurrent(home).empty()); - ::mkdir((home + "/contrib_2026-01-01_00-00-00").c_str(), 0755); - CHECK(findCurrent(home).empty()); // (no info.txt) - // Made with the consent - std::string dir; - Info info; + ::mkdir(dir.c_str(), 0755); + // info.txt: its header once, then the sections in order, on the disk std::string error; - CHECK(create(home, "0.8.2-test", static_cast(kNow), dir, info, error)); - SAME(dir, home + "/" + folderName(static_cast(kNow))); - SAME(info.appVersion, "0.8.2-test"); - CHECK(!info.consent.empty()); - SAME(findCurrent(home), dir); - Info read; - CHECK(readInfo(dir, read, error)); - SAME(read.consent, info.consent); - SAME(read.steps[3].protocol, kFreeProtocol); - // A second in the same second: the next second's name; the newest is the current one - std::string second; - Info secondInfo; - CHECK(create(home, "0.8.2-test", static_cast(kNow), second, secondInfo, error)); - CHECK(second != dir && isFolderName(second.substr(home.size() + 1))); - SAME(findCurrent(home), second); - std::filesystem::remove_all(second); - // A recording moved in, and one that didn't finish into aborted/ - const std::string rec = makeRecording(home, "rec_2026-10-25_14-02-41", 4096); + CHECK(appendInfo(dir, consentSection("2026-10-25 14:02:10 +0900", 1, Eyewear::None), error)); + CHECK(appendInfo(dir, abortedSection("aborted/rec_2026-10-25_14-04-00", "stopped"), error)); + const std::string info = readFile(dir + "/" + kInfoFile); + CHECK(info.rfind(headerText(), 0) == 0); + CHECK(info.find("# frameeyeosc", 1) == std::string::npos); + CHECK(info.find("[consent]") < info.find("[aborted]")); + CHECK(!appendInfo(temp.path + "/missing", "x", error) && !error.empty()); + // The files a recording needs + const std::string full = makeRecording(home, "rec_2026-10-25_14-02-41", 4096); + CHECK(missingFiles(full).empty()); + const std::string lacking = makeRecording(home, "rec_2026-10-25_14-03-00", 100, kNow, "cues.csv"); + CHECK(missingFiles(lacking) == std::vector {"cues.csv"}); + CHECK(missingFiles(temp.path + "/none").size() == 4); + // Moved into the folder, and one that didn't finish into aborted/ std::string name; - CHECK(takeRecording(home, dir, rec, false, name, error)); + CHECK(takeRecording(home, dir, full, false, name, error)); SAME(name, "rec_2026-10-25_14-02-41"); - CHECK(::access((dir + "/" + name + "/eye_L.raw").c_str(), F_OK) == 0); - CHECK(::access(rec.c_str(), F_OK) != 0); - const std::string part = makeRecording(home, "rec_2026-10-25_14-04-00", 1000); - CHECK(takeRecording(home, dir, part, true, name, error)); - SAME(name, "aborted/rec_2026-10-25_14-04-00"); + CHECK(there(dir + "/" + name + "/eye_L.raw") && !there(full)); + CHECK(takeRecording(home, dir, lacking, true, name, error)); + SAME(name, "aborted/rec_2026-10-25_14-03-00"); // Only eyecam-rec's own folders right in home const std::string elsewhere = temp.path + "/rec_2026-10-25_14-09-00"; ::mkdir(elsewhere.c_str(), 0755); @@ -726,17 +684,17 @@ void testFiles() { CHECK(!takeRecording(home, dir, home + "/calib_2026-10-25_14-09-00", false, name, error)); CHECK(!takeRecording(home, dir, home + "/rec_2026-10-25_14-59-59", false, name, error)); // (not there) // Sizes: the files in it and its folders - CHECK(folderBytes(dir + "/rec_2026-10-25_14-02-41") == - 4096 + 4096 - 13 + 20 + 13 + 10 + 23); + CHECK(folderBytes(dir + "/rec_2026-10-25_14-02-41") == 4096 + 4096 - 13 + 20 + 13 + 10 + 17); CHECK(folderBytes(dir) > folderBytes(dir + "/rec_2026-10-25_14-02-41")); CHECK(freeBytes(home) > 0); CHECK(freeBytes(temp.path + "/missing") == -1); - // info.txt written over (no temporary left) - info.steps[0].rec = "rec_2026-10-25_14-02-41"; - CHECK(writeInfo(dir, info, error)); - CHECK(readInfo(dir, read, error)); - SAME(read.steps[0].rec, "rec_2026-10-25_14-02-41"); - CHECK(::access((dir + "/info.txt.tmp").c_str(), F_OK) != 0); + // The newest calibration result (by name), skipping a folder without one + CHECK(newestCalibResult(home).empty()); + ::mkdir((home + "/calib_2026-10-24_10-00-00").c_str(), 0755); + writeFile(home + "/calib_2026-10-24_10-00-00/calib_result.json", "{\"old\":1}"); + ::mkdir((home + "/calib_2026-10-25_09-00-00").c_str(), 0755); + writeFile(home + "/calib_2026-10-25_09-00-00/calib_result.json", "{\"new\":1}"); + SAME(newestCalibResult(home), "{\"new\":1}"); } /** @@ -765,82 +723,126 @@ void testPack() { using namespace contrib; const RemoveDir temp {tempDir()}; const std::string home = temp.path + "/eyecam"; + const std::string dir = home + "/" + kFolderName; ::mkdir(home.c_str(), 0755); - std::string dir; - Info info; + ::mkdir(dir.c_str(), 0755); std::string error; - CHECK(create(home, "0.8.2-test", static_cast(kNow), dir, info, error)); - const std::string names[2] = {"rec_2026-10-25_14-02-41", "rec_2026-10-25_14-05-12"}; std::string name; - takeRecording(home, dir, makeRecording(home, names[0], 300'000), false, name, error); - takeRecording(home, dir, makeRecording(home, names[1], 1'500'000), false, name, error); + const std::string names[3] = {"rec_2026-10-25_14-02-41", "rec_2026-10-25_14-05-12", "rec_2026-10-25_14-07-30"}; + Info info; + for (int i = 0; i < 3; ++i) { + takeRecording(home, dir, makeRecording(home, names[i], i == 1 ? 1'500'000 : 300'000, kNow), false, name, error); + contrib::Recording r; + r.rec = names[i]; + r.calib = i == 0 ? names[i] + ".calib_result.json" : "none"; + info.recordings.push_back(r); + appendInfo(dir, recordingSection(r), error); + } + writeFile(dir + "/" + names[0] + ".calib_result.json", "{\"calib\":1}"); + // (the third lost a file since: left out and named) + ::unlink((dir + "/" + names[2] + "/valve.csv").c_str()); takeRecording(home, dir, makeRecording(home, "rec_2026-10-25_14-04-00", 1000), true, name, error); - info.steps[0].rec = names[0]; - info.steps[1].rec = names[1]; - CHECK(writeInfo(dir, info, error)); - // What goes in: the folder, info.txt, the finished recordings (sorted), not aborted/ + writeFile(dir + "/contrib_2026-10-24_09-00-00.tar.zst", "an older pack"); + // What goes in: the folder, info.txt, the complete recordings (sorted) and the calibration result; not aborted/, + // not a pack std::vector entries; - CHECK(packEntries(dir, info, entries, error)); - CHECK(entries.size() == 2 + 2 * 7); - const std::string top = dir.substr(home.size() + 1); - SAME(entries[0].name, top + "/"); - SAME(entries[1].name, top + "/info.txt"); - SAME(entries[2].name, top + "/" + names[0] + "/"); - SAME(entries[3].name, top + "/" + names[0] + "/cues.csv"); - for (const Entry& entry : entries) CHECK(entry.name.find("aborted") == std::string::npos); - // A finished one gone missing: nothing is packed - Info missing = info; - missing.steps[2].rec = "rec_2026-10-25_15-00-00"; - CHECK(!packEntries(dir, missing, entries, error)); + std::vector incomplete; + CHECK(packEntries(dir, info, entries, incomplete, error)); + CHECK(incomplete == std::vector {names[2]}); + CHECK(entries.size() == 2 + 7 + 1 + 7); + SAME(entries[0].name, "contrib/"); + SAME(entries[1].name, "contrib/info.txt"); + SAME(entries[2].name, "contrib/" + names[0] + "/"); + SAME(entries[3].name, "contrib/" + names[0] + "/cues.csv"); + SAME(entries[9].name, "contrib/" + names[0] + ".calib_result.json"); + for (const Entry& entry : entries) { + CHECK(entry.name.find("aborted") == std::string::npos && entry.name.find(".tar") == std::string::npos); + CHECK(entry.name.find(names[2]) == std::string::npos); + } // Packed as tar: unpacks to the same files Worker worker; - CHECK(worker.pack(dir, info, false)); + const std::string tarName = packName(static_cast(kNow), false); + CHECK(worker.pack(dir, entries, tarName, 2, false)); Progress ended = waitFor(worker); - CHECK(ended.finished && ended.ok); - SAME(ended.output, top + ".tar"); + CHECK(ended.finished && ended.ok && ended.count == 2); + SAME(ended.output, tarName); CHECK(ended.done == ended.total && ended.total > 0); - const std::string tar = dir + "/" + top + ".tar"; - CHECK(::access(tar.c_str(), F_OK) == 0); - CHECK(::access((tar + ".part").c_str(), F_OK) != 0); + const std::string tar = dir + "/" + tarName; + CHECK(there(tar) && !there(tar + ".part")); CHECK(ended.outputBytes == static_cast(std::filesystem::file_size(tar))); const std::string out = temp.path + "/out"; ::mkdir(out.c_str(), 0755); CHECK(run("tar -xf '" + tar + "' -C '" + out + "'")); - for (const std::string& rec : names) { + for (int i = 0; i < 2; ++i) { for (const char* file : {"eye_L.raw", "eye_R.raw", "cues.csv", "valve.csv", "frames.csv", "meta.txt"}) { - const std::string a = readFile(dir + "/" + rec + "/" + file); - const std::string b = readFile(out + "/" + top + "/" + rec + "/" + file); - CHECK(!a.empty() && a == b); + const std::string a = readFile(dir + "/" + names[i] + "/" + file); + CHECK(!a.empty() && a == readFile(out + "/contrib/" + names[i] + "/" + file)); } } - SAME(readFile(out + "/" + top + "/info.txt"), readFile(dir + "/info.txt")); - CHECK(::access((out + "/" + top + "/aborted").c_str(), F_OK) != 0); + SAME(readFile(out + "/contrib/info.txt"), readFile(dir + "/info.txt")); + SAME(readFile(out + "/contrib/" + names[0] + ".calib_result.json"), "{\"calib\":1}"); + CHECK(!there(out + "/contrib/aborted") && !there(out + "/contrib/" + names[2])); // ...and through zstd, when it is there if (zstdAvailable()) { - CHECK(worker.pack(dir, info, true)); + const std::string zstName = packName(static_cast(kNow) + 1, true); + CHECK(worker.pack(dir, entries, zstName, 2, true)); ended = waitFor(worker); CHECK(ended.finished && ended.ok); - SAME(ended.output, top + ".tar.zst"); - const std::string zst = dir + "/" + top + ".tar.zst"; const std::string out2 = temp.path + "/out2"; ::mkdir(out2.c_str(), 0755); - CHECK(run("zstd -dc '" + zst + "' | tar -xf - -C '" + out2 + "'")); - SAME(readFile(out2 + "/" + top + "/" + names[1] + "/eye_L.raw"), - readFile(dir + "/" + names[1] + "/eye_L.raw")); - // (the earlier pack and aborted/ aren't in it) - CHECK(::access((out2 + "/" + top + "/" + top + ".tar").c_str(), F_OK) != 0); + CHECK(run("zstd -dc '" + dir + "/" + zstName + "' | tar -xf - -C '" + out2 + "'")); + SAME(readFile(out2 + "/contrib/" + names[1] + "/eye_L.raw"), readFile(dir + "/" + names[1] + "/eye_L.raw")); } else { std::printf("contrib-test: no zstd here, its pack not tried\n"); } - // Deleting: only a contribution's folder right in home + // Deleting: only home/contrib itself CHECK(!worker.remove(home, dir + "/" + names[0])); CHECK(!worker.remove(temp.path, dir)); - CHECK(!worker.remove(home, home + "/contrib_2026-10-25_99-99-99")); CHECK(worker.remove(home, dir)); ended = waitFor(worker); CHECK(ended.finished && ended.ok && ended.job == Job::Delete); - CHECK(::access(dir.c_str(), F_OK) != 0); - CHECK(::access(home.c_str(), F_OK) == 0); + CHECK(!there(dir) && there(home)); +} + +/** + * Run one recording through a session: started, answered, recording, then idle with its folder. + * @param session the session + * @param view its view + * @param home eyecam-rec's folder + * @param now the tests' clock (moved on) + * @param eyecam eyecam-rec as it is before (its count and run) + * @param name the recording's folder + * @param without a file it lacks + * @param aborted it ended early + */ +void record(contrib::Session& session, contrib::View& view, const std::string& home, double& now, eyecam::View eyecam, + const std::string& name, const std::string& without = "", bool aborted = false) { + std::string log; + contrib::Snapshot snapshot; + snapshot.appVersion = "0.8.2-test"; + snapshot.eyecamRec = "294d06c6"; + eyecam.status.mtime = now; + const std::string command = session.startCommand(view, eyecam); + SAME(command, "start contrib"); + session.started(view, eyecam, snapshot, command, now); + eyecam::Reply ok; + ok.ok = true; + ok.command = command; + CHECK(!session.replied(view, ok)); + now += 1; + eyecam::View running = eyecam; + running.status = status(State::Recording, contrib::kProtocol, now); + session.follow(view, running, now, now, log); + CHECK(view.running); + const std::string rec = makeRecording(home, name, 600, now, without); + now += aborted ? 20 : 96; + eyecam::View after = eyecam; + after.status.mtime = now; + after.status.sessionDir = rec; + after.status.protocol = contrib::kProtocol; + after.status.lastSessionAborted = aborted; + CHECK(session.follow(view, after, now, now, log)); + CHECK(!view.running); } void testSession() { @@ -848,177 +850,129 @@ void testSession() { const RemoveDir temp {tempDir()}; const std::string home = temp.path + "/eyecam"; ::mkdir(home.c_str(), 0755); + ::mkdir((home + "/calib_2026-10-25_09-00-00").c_str(), 0755); + writeFile(home + "/calib_2026-10-25_09-00-00/calib_result.json", "{\"ok\":true}"); View view; Session session; std::string log; session.load(view, home); - CHECK(view.loaded && view.dir.empty() && view.freeBytes > 0); - CHECK(screen(view, false) == Screen::Consent); - const double t0 = static_cast(std::time(nullptr)); - double now = t0; - double boot = 1000; - eyecam::View eyecam = eyecamReady(now); - // No start before the consent - CHECK(session.startCommand(view, eyecam, 0).empty()); - CHECK(session.consent(view, "0.8.2-test", static_cast(now))); - CHECK(!view.dir.empty()); - CHECK(!session.consent(view, "0.8.2-test", static_cast(now))); - // Step 1: no re-wear before it - SAME(session.startCommand(view, eyecam, 0), "start widen_nolight"); - CHECK(session.startCommand(view, eyecam, 1).empty()); - session.started(view, 0, "start widen_nolight", now); - CHECK(view.running == 0 && screen(view, false) == Screen::Running); - // (the start went out: nothing more until its reply) - CHECK(session.startCommand(view, eyecam, 0).empty()); - eyecam::Reply ok; - ok.ok = true; - ok.command = "start widen_nolight"; - CHECK(!session.replied(view, ok)); - // Recording, then idle with its folder: moved into the contribution - now += 1; - eyecam.status = status(State::Recording, kGuidedProtocol, now); - session.follow(view, eyecam, now, boot, log); - CHECK(view.running == 0); - const std::string rec1 = makeRecording(home, "rec_2026-10-25_14-02-41", 2000, now); - now += 61; - eyecam.status = status(State::Idle, kGuidedProtocol, now); - eyecam.status.sessionDir = rec1; - CHECK(session.follow(view, eyecam, now, boot, log)); - CHECK(view.running == -1); - SAME(view.info.steps[0].rec, "rec_2026-10-25_14-02-41"); - SAME(view.info.steps[0].wear, kWearFirst); - CHECK(view.info.steps[0].bytes > 4000); - CHECK(::access((view.dir + "/rec_2026-10-25_14-02-41/cues.csv").c_str(), F_OK) == 0); - CHECK(log.find("step 1 recorded") != std::string::npos); - // Step 2 waits for the re-wear: the video stops, then comes back - CHECK(screen(view, false) == Screen::Rewear); - CHECK(session.startCommand(view, eyecam, 1).empty()); - for (int i = 0; i < 40; ++i) { - now += 0.1; - boot += 0.1; - eyecam.status = status(State::Idle, kGuidedProtocol, now); - eyecam.status.locked = false; - session.follow(view, eyecam, now, boot, log); - } - CHECK(view.offSeen && view.info.nextWear.empty()); - for (int i = 0; i < 25; ++i) { - now += 0.1; - boot += 0.1; - eyecam.status = status(State::Idle, kGuidedProtocol, now); - session.follow(view, eyecam, now, boot, log); - } - SAME(view.info.nextWear, kWearDetected); - CHECK(screen(view, false) == Screen::Ready); - // Step 2 stopped part way: its part in aborted/, the step again (its re-wear kept) - eyecam = eyecamReady(now); - SAME(session.startCommand(view, eyecam, 1), "start widen_nolight"); - session.started(view, 1, "start widen_nolight", now); - session.replied(view, ok); - now += 1; - eyecam.status = status(State::Recording, kGuidedProtocol, now); - session.follow(view, eyecam, now, boot, log); - const std::string part = makeRecording(home, "rec_2026-10-25_14-05-12", 500, now); - now += 20; - eyecam.status = status(State::Idle, kGuidedProtocol, now); - eyecam.status.sessionDir = part; - eyecam.status.lastSessionAborted = true; - CHECK(session.follow(view, eyecam, now, boot, log)); - CHECK(view.failure.step == 1 && view.failure.why == Why::Stopped); - SAME(view.failure.kept, "aborted/rec_2026-10-25_14-05-12"); - CHECK(view.info.steps[1].rec.empty()); - CHECK(view.info.aborted.size() == 1); - CHECK(screen(view, false) == Screen::Failed); - SAME(view.info.nextWear, kWearDetected); + CHECK(view.loaded && !view.exists && view.freeBytes > 0); + CHECK(screen(view, Page::Record, false) == Screen::Consent); + double now = static_cast(std::time(nullptr)); + const double started = now - 3600; + // No start before the consent, nor without an answer + CHECK(session.startCommand(view, eyecamReady(now)).empty()); + CHECK(!session.consent(view, Eyewear::Unasked, static_cast(now))); + CHECK(session.consent(view, Eyewear::Glasses, static_cast(now))); + CHECK(view.exists && view.info.eyewear == Eyewear::Glasses && !consentNeeded(view.info)); + CHECK(screen(view, Page::Record, false) == Screen::Ready); + // The first: moved in, its section with the settings and the calibration result beside it + record(session, view, home, now, eyecamReady(now, 2, started), "rec_2026-10-25_14-02-41"); + CHECK(view.justDone && screen(view, Page::Record, false) == Screen::Done); + CHECK(view.info.recordings.size() == 1); + if (view.info.recordings.size() == 1) { + const Recording& r = view.info.recordings[0]; + SAME(r.rec, "rec_2026-10-25_14-02-41"); + SAME(r.protocol, kProtocol); + SAME(r.appVersion, "0.8.2-test"); + SAME(r.eyecamRec, "294d06c6"); + SAME(r.lidVeto, "on"); + SAME(r.widenSensitivity, "0.60"); + SAME(r.eyewear, "glasses"); + SAME(r.sincePrevious, "none"); + SAME(r.headsetOff, "none"); + CHECK(r.offCountStart == 2 && r.offCountEnd == 2); + CHECK(std::fabs(r.fps - 89.9) < 1e-6); + SAME(r.calib, "rec_2026-10-25_14-02-41.calib_result.json"); + CHECK(r.bytes > 1000); + } + SAME(readFile(view.dir + "/rec_2026-10-25_14-02-41.calib_result.json"), "{\"ok\":true}"); + CHECK(there(view.dir + "/rec_2026-10-25_14-02-41/cues.csv") && !there(home + "/rec_2026-10-25_14-02-41")); + // The second, 10 minutes later with the headset taken off between (the count went up) + now += 600; + record(session, view, home, now, eyecamReady(now, 3, started), "rec_2026-10-25_14-12-41"); + CHECK(view.info.recordings.size() == 2); + if (view.info.recordings.size() == 2) { + SAME(view.info.recordings[1].headsetOff, "yes"); + SAME(view.info.recordings[1].sincePrevious, "10.0"); + } + // The third right after it (no taking-off), the fourth after eyecam-rec restarted (unknown) + record(session, view, home, now, eyecamReady(now, 3, started), "rec_2026-10-25_14-14-30"); + record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-16-20"); + CHECK(view.info.recordings.size() == 4); + if (view.info.recordings.size() == 4) { + SAME(view.info.recordings[2].headsetOff, "no"); + SAME(view.info.recordings[2].sincePrevious, "0.0"); + SAME(view.info.recordings[3].headsetOff, "unknown"); + } + // One lacking a file: into aborted/, the screen says which, to record again + record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-18-00", "frames.csv"); + CHECK(view.info.recordings.size() == 4 && view.failure.shown && view.failure.why == Why::Incomplete); + SAME(view.failure.message, "frames.csv"); + SAME(view.failure.kept, "aborted/rec_2026-10-25_14-18-00"); + CHECK(screen(view, Page::Record, false) == Screen::Failed); + CHECK(there(view.dir + "/aborted/rec_2026-10-25_14-18-00")); + // One stopped part way: into aborted/ too + record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-19-00", "", true); + CHECK(view.failure.why == Why::Stopped && view.info.aborted.size() == 2); // Refused at once (a reply with an error): failed with its reason - eyecam = eyecamReady(now); - session.started(view, 1, "start widen_nolight", now); - eyecam::Reply refused; - refused.command = "start widen_nolight"; - refused.error = "プロトコルが見つからない: protocol_widen_nolight.txt"; - CHECK(session.replied(view, refused)); - CHECK(view.running == -1 && view.failure.why == Why::Refused); - SAME(view.failure.message, refused.error); - // The panel restarts while step 2 records: followed on after the load, done - session.started(view, 1, "start widen_nolight", now); - session.replied(view, ok); + { + const eyecam::View eyecam = eyecamReady(now); + session.started(view, eyecam, Snapshot(), "start contrib", now); + eyecam::Reply refused; + refused.command = "start contrib"; + refused.error = "プロトコルが見つからない: protocol_contrib.txt"; + CHECK(session.replied(view, refused)); + CHECK(!view.running && view.failure.why == Why::Refused); + SAME(view.failure.message, refused.error); + } + // Read again from the disk: the same { View again; Session restarted; restarted.load(again, home); - SAME(again.dir, view.dir); - CHECK(again.running == 1); - const std::string rec2 = makeRecording(home, "rec_2026-10-25_14-08-03", 800, now); - now += 70; - eyecam.status = status(State::Idle, kGuidedProtocol, now); - eyecam.status.sessionDir = rec2; - CHECK(restarted.follow(again, eyecam, now, boot, log)); - SAME(again.info.steps[1].rec, "rec_2026-10-25_14-08-03"); - SAME(again.info.steps[1].wear, kWearDetected); - CHECK(again.info.nextWear.empty()); - // "I put it back on" for step 3 (only once a step waits for one) - CHECK(restarted.rewore(again)); - CHECK(!restarted.rewore(again)); - Info onDisk; - std::string error; - CHECK(readInfo(again.dir, onDisk, error)); - SAME(onDisk.nextWear, kWearButton); - CHECK(onDisk.running == -1); - view = again; - session.load(view, home); - } - // Steps 3 and 4 (the free use) done: the end, and its pack - for (int step = 2; step < kRequiredSteps; ++step) { - if (view.info.nextWear.empty()) CHECK(session.rewore(view)); - eyecam = eyecamReady(now); - SAME(session.startCommand(view, eyecam, step), std::string("start ") + protocolOf(step)); - session.started(view, step, std::string("start ") + protocolOf(step), now); - // (followed only once its start is answered) - eyecam::Reply started = ok; - started.command = std::string("start ") + protocolOf(step); - CHECK(!session.replied(view, started)); - now += 1; - eyecam.status = status(State::Recording, protocolOf(step), now); - session.follow(view, eyecam, now, boot, log); - const std::string rec = - makeRecording(home, step == 2 ? "rec_2026-10-25_14-11-40" : "rec_2026-10-25_14-15-00", 600, now); - now += 60; - eyecam.status = status(State::Idle, protocolOf(step), now); - eyecam.status.sessionDir = rec; - CHECK(session.follow(view, eyecam, now, boot, log)); - CHECK(!view.info.steps[step].rec.empty()); + CHECK(again.exists && again.info.recordings.size() == 4 && again.info.aborted.size() == 2); + CHECK(again.info.eyewear == Eyewear::Glasses && !consentNeeded(again.info)); } - CHECK(requiredDone(view.info) == kRequiredSteps); - CHECK(screen(view, false) == Screen::Done); - SAME(view.info.steps[3].protocol, kFreeProtocol); + // Packed (tar); another recording, packed again: the first pack goes, the recordings stay view.compress = false; - CHECK(session.pack(view)); + CHECK(session.pack(view, static_cast(now))); for (int i = 0; i < 3000 && view.info.packed.empty(); ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); - session.follow(view, eyecam, now, boot, log); + session.follow(view, eyecamReady(now), now, now, log); + } + const std::string first = view.info.packed; + CHECK(isPackName(first) && view.info.packedCount == 4 && view.info.packedBytes > 0); + CHECK(there(view.dir + "/" + first)); + record(session, view, home, now, eyecamReady(now), "rec_2026-10-25_14-30-00"); + CHECK(session.pack(view, static_cast(now) + 60)); + for (int i = 0; i < 3000 && view.info.packed == first; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + session.follow(view, eyecamReady(now), now, now, log); } - SAME(view.info.packed, view.dir.substr(home.size() + 1) + ".tar"); - CHECK(view.info.packedBytes > 0); - CHECK(view.totalBytes > view.info.packedBytes); - // Deleted: the first screen again, saying so + CHECK(view.info.packed != first && view.info.packedCount == 5); + CHECK(!there(view.dir + "/" + first) && there(view.dir + "/" + view.info.packed)); + CHECK(there(view.dir + "/rec_2026-10-25_14-02-41/eye_L.raw")); + // Deleted: the consent again, saying so CHECK(session.remove(view)); - for (int i = 0; i < 3000 && !view.dir.empty(); ++i) { + for (int i = 0; i < 3000 && view.exists; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); - session.follow(view, eyecam, now, boot, log); + session.follow(view, eyecamReady(now), now, now, log); } - CHECK(view.dir.empty() && view.deleted); - CHECK(screen(view, false) == Screen::Consent); - CHECK(findCurrent(home).empty()); + CHECK(!view.exists && view.deleted && !there(home + "/" + kFolderName)); + CHECK(screen(view, Page::Record, false) == Screen::Consent); + CHECK(there(home + "/calib_2026-10-25_09-00-00/calib_result.json")); } } // namespace int main() { - testSteps(); - testProtocolFiles(); + testSizes(); + testProtocolFile(); testInfo(); testNames(); + testHeadsetOff(); testRunWatch(); - testWearWatch(); testScreen(); testTarHeader(); testFiles(); diff --git a/panel/tests/panel_test.cpp b/panel/tests/panel_test.cpp index 1a7066b..1402ad2 100644 --- a/panel/tests/panel_test.cpp +++ b/panel/tests/panel_test.cpp @@ -3001,8 +3001,9 @@ size_t hitsArg(const EyePanel& panel, PanelAction action, int arg) { } void testContrib(const FontSet& fonts) { - // "Help train the AI": its row on Debug tools (only while eyecam-rec runs, usable once the cameras are set up), - // each screen's buttons in both languages, its recording shown in it, the delete's confirmation, and what closes it + // "Record for squint training": its row on Debug tools (only while eyecam-rec runs, usable once the cameras are set + // up; "Recordings" once there is a folder), each screen's buttons in both languages, its recording shown in it, the + // delete's confirmation, and what closes it { EyePanel panel(fonts); panel.setTab(PanelTab::Advanced); @@ -3015,6 +3016,8 @@ void testContrib(const FontSet& fonts) { panel.render(notSetUp); CHECK(hits(panel, PanelAction::ContribOpen).empty()); } + const int record = static_cast(contrib::Page::Record); + const int manage = static_cast(contrib::Page::Manage); for (const Language language : {Language::Ja, Language::En}) { EyePanel panel(fonts); panel.setTab(PanelTab::Advanced); @@ -3022,97 +3025,93 @@ void testContrib(const FontSet& fonts) { PanelModel m = modelWith(Lids::Both, true); m.language = language; m.contrib.loaded = true; + m.contrib.dir = "/home/steamos/eyecam/contrib"; m.contrib.freeBytes = 30'000'000'000LL; panel.render(m); - CHECK(press(panel, PanelAction::ContribOpen).action == PanelAction::ContribOpen); - CHECK(panel.contribOpen() && !panel.advPageShown()); - // The consent: "I'll help", the cross and "Cancel"; not without the room for it + CHECK(hitsArg(panel, PanelAction::ContribOpen, record) == 1); + CHECK(hitsArg(panel, PanelAction::ContribOpen, manage) == 0); + CHECK(pressArg(panel, PanelAction::ContribOpen, record).action == PanelAction::ContribOpen); + CHECK(panel.contribOpen() && panel.contribPage() == contrib::Page::Record && !panel.advPageShown()); + // The consent: "Agree and continue", the cross and "Cancel", no sub-tabs panel.render(m); - CHECK(hits(panel, PanelAction::ContribConsent).size() == 1); + CHECK(hitsArg(panel, PanelAction::ContribAgree, 1) == 1); CHECK(hits(panel, PanelAction::ContribClose).size() == 2); - CHECK(hits(panel, PanelAction::AdvancedPage).empty()); - m.contrib.freeBytes = 5'000'000'000LL; - panel.render(m); - CHECK(hits(panel, PanelAction::ContribConsent).empty()); - m.contrib.freeBytes = 30'000'000'000LL; - // Begun: the first step's start (no re-wear before it); none while a command waits - m.contrib.dir = "/home/steamos/eyecam/contrib_2026-10-25_14-02-10"; - panel.render(m); - CHECK(hitsArg(panel, PanelAction::ContribStart, 0) == 1); - CHECK(hits(panel, PanelAction::ContribDelete).size() == 1); + CHECK(hits(panel, PanelAction::AdvancedPage).empty() && hits(panel, PanelAction::ContribStart).empty()); + CHECK(pressArg(panel, PanelAction::ContribAgree, 1).action == PanelAction::None); + CHECK(panel.contribAgreed()); + // ...then glasses or inserts: four answers (the caller writes the consent with it), and "Back" + panel.render(m); + CHECK(hits(panel, PanelAction::ContribEyewear).size() == 4); + CHECK(hitsArg(panel, PanelAction::ContribAgree, 0) == 1); + const int inserts = static_cast(contrib::Eyewear::Inserts); + const PanelHit answer = pressArg(panel, PanelAction::ContribEyewear, inserts); + CHECK(answer.action == PanelAction::ContribEyewear && answer.arg == inserts); + CHECK(!panel.contribAgreed()); + m.contrib.exists = true; + m.contrib.info.consentVersion = contrib::kConsentVersion; + m.contrib.info.eyewear = contrib::Eyewear::Inserts; + // The start; none while a command waits or without the room + panel.render(m); + CHECK(hits(panel, PanelAction::ContribStart).size() == 1); m.eyecam.busy = true; panel.render(m); CHECK(hits(panel, PanelAction::ContribStart).empty()); m.eyecam.busy = false; - // After it: the re-wear, "I put it back on" only once it can be pressed; then the second step's start - m.contrib.info.steps[0].rec = "rec_2026-10-25_14-02-41"; - panel.render(m); - CHECK(hits(panel, PanelAction::ContribStart).empty() && hits(panel, PanelAction::ContribRewore).empty()); - m.contrib.fallbackReady = true; + m.contrib.freeBytes = 2'000'000'000LL; panel.render(m); - CHECK(hits(panel, PanelAction::ContribRewore).size() == 1); - m.contrib.info.nextWear = contrib::kWearDetected; - panel.render(m); - CHECK(hitsArg(panel, PanelAction::ContribStart, 1) == 1); + CHECK(hits(panel, PanelAction::ContribStart).empty()); + m.contrib.freeBytes = 30'000'000'000LL; // Its recording runs in the sheet: the eye capture's run with its "Stop" - m.contrib.running = 1; + m.contrib.running = true; eyecam::Status& s = m.eyecam.status; s.state = eyecam::State::Recording; s.stateText = "recording"; s.locked = true; - s.protocol = contrib::kGuidedProtocol; - s.stepLabel = "widen"; - s.stepCount = 14; - s.stepIndex = 3; - s.elapsedS = 10; - s.totalS = 60.5; + s.protocol = contrib::kProtocol; + s.stepLabel = "look_left"; + s.stepCount = 23; + s.stepIndex = 19; + s.elapsedS = 80; + s.totalS = 94.5; s.stepRemainingS = 3; panel.render(m); CHECK(hits(panel, PanelAction::EyecamStop).size() == 1); CHECK(hits(panel, PanelAction::ContribStart).empty() && hits(panel, PanelAction::ContribDelete).empty()); s.state = eyecam::State::Idle; s.stateText = "idle"; - m.contrib.running = -1; - // It didn't finish: "Record again" for that step - m.contrib.failure.step = 1; - m.contrib.failure.why = contrib::Why::Stopped; + m.contrib.running = false; + // It didn't finish: "Record again" + m.contrib.failure.shown = true; + m.contrib.failure.why = contrib::Why::Incomplete; + m.contrib.failure.message = "cues.csv"; panel.render(m); - CHECK(hitsArg(panel, PanelAction::ContribStart, 1) == 1); + CHECK(hits(panel, PanelAction::ContribStart).size() == 1); m.contrib.failure = contrib::Failure(); - // All four: pack, delete, the optional one - for (int step = 1; step < contrib::kRequiredSteps; ++step) { - m.contrib.info.steps[step].rec = "rec_2026-10-25_14-05-12"; - } - m.contrib.info.nextWear.clear(); - panel.render(m); - CHECK(hits(panel, PanelAction::ContribPack).size() == 1); - CHECK(hits(panel, PanelAction::ContribDelete).size() == 1); - CHECK(hitsArg(panel, PanelAction::ContribExtra, 1) == 1); - CHECK(hits(panel, PanelAction::ContribStart).empty()); - // ...the optional one: its re-wear first, then its start and "Back" - CHECK(pressArg(panel, PanelAction::ContribExtra, 1).action == PanelAction::None); - CHECK(panel.contribExtra()); - panel.render(m); - CHECK(hits(panel, PanelAction::ContribRewore).size() == 1); - m.contrib.info.nextWear = contrib::kWearButton; - panel.render(m); - CHECK(hitsArg(panel, PanelAction::ContribStart, 4) == 1); - CHECK(pressArg(panel, PanelAction::ContribExtra, 0).action == PanelAction::None); - CHECK(!panel.contribExtra()); - // Packing or packed: no second one, and no delete while packing + // Recorded: pack, delete, close (and the cross) + m.contrib.justDone = true; + m.contrib.info.recordings.resize(2); + m.contrib.info.recordings[0].bytes = m.contrib.info.recordings[1].bytes = 2'700'000'000LL; + panel.render(m); + CHECK(hits(panel, PanelAction::ContribPack).size() == 1 && hits(panel, PanelAction::ContribDelete).size() == 1); + CHECK(hits(panel, PanelAction::ContribClose).size() == 2 && hits(panel, PanelAction::ContribStart).empty()); + // Packing: no second one, no delete; packed with all of them: none until there are more m.contrib.progress.job = contrib::Job::Pack; m.contrib.progress.running = true; panel.render(m); CHECK(hits(panel, PanelAction::ContribPack).empty() && hits(panel, PanelAction::ContribDelete).empty()); m.contrib.progress = contrib::Progress(); - m.contrib.info.packed = "contrib_2026-10-25_14-02-10.tar.zst"; + m.contrib.info.packed = "contrib_2026-10-25_14-25-02.tar.zst"; + m.contrib.info.packedCount = 2; panel.render(m); CHECK(hits(panel, PanelAction::ContribPack).empty()); - // Deleting: its confirmation first (only its buttons), "Cancel" back, then the delete + m.contrib.info.recordings.resize(3); + panel.render(m); + CHECK(hits(panel, PanelAction::ContribPack).size() == 1); + // Deleting: its confirmation first (only its buttons), "Cancel" back, then the delete (back to "Record") CHECK(press(panel, PanelAction::ContribDelete).action == PanelAction::None); panel.render(m); CHECK(hits(panel, PanelAction::ContribDeleteConfirm).size() == 1); - CHECK(hits(panel, PanelAction::ContribDelete).empty() && hits(panel, PanelAction::ContribExtra).empty()); + CHECK(hits(panel, PanelAction::ContribDelete).empty() && hits(panel, PanelAction::ContribPack).empty()); CHECK(press(panel, PanelAction::ContribDeleteCancel).action == PanelAction::None); panel.render(m); CHECK(hits(panel, PanelAction::ContribDeleteConfirm).empty()); @@ -3120,13 +3119,18 @@ void testContrib(const FontSet& fonts) { press(panel, PanelAction::ContribDelete); panel.render(m); CHECK(press(panel, PanelAction::ContribDeleteConfirm).action == PanelAction::ContribDeleteConfirm); - // A tab closes it; the sub-tabs show again + // A tab closes it; the row offers "Recordings" now there is a folder, which opens the end's screen pressTab(panel, PanelTab::Basic); CHECK(!panel.contribOpen()); panel.render(m); pressTab(panel, PanelTab::Advanced); + m.contrib.justDone = false; + panel.render(m); + CHECK(panel.advPageShown() && hitsArg(panel, PanelAction::ContribOpen, manage) == 1); + pressArg(panel, PanelAction::ContribOpen, manage); + CHECK(panel.contribPage() == contrib::Page::Manage); panel.render(m); - CHECK(panel.advPageShown() && hits(panel, PanelAction::ContribOpen).size() == 1); + CHECK(hits(panel, PanelAction::ContribPack).size() == 1 && hits(panel, PanelAction::ContribStart).empty()); } } From 0c499e408062ab929d63a3b7ef4825c0dbbbfcc6 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 12:38:13 +0900 Subject: [PATCH 37/65] =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88:=20=E3=80=8C=E7=B4=B0=E3=82=81=E3=81=AE?= =?UTF-8?q?=E5=AD=A6=E7=BF=92=E7=94=A8=E3=81=AB=E6=92=AE=E3=82=8B=E3=80=8D?= =?UTF-8?q?=E3=81=AE=E4=BB=95=E6=A7=98=E3=80=81eyecam=20=E3=81=AE=E6=96=B0?= =?UTF-8?q?=E3=81=97=E3=81=84=E6=89=8B=E9=A0=86=E3=81=A8=20status.json=20?= =?UTF-8?q?=E3=81=AE=E9=A0=85=E7=9B=AE=E3=80=81=E6=9B=B4=E6=96=B0=E5=B1=A5?= =?UTF-8?q?=E6=AD=B4=EF=BC=88Unreleased=EF=BC=89=E3=82=92=E6=9B=B8?= =?UTF-8?q?=E3=81=8D=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 10 +++++----- CHANGELOG.md | 12 ++++++------ README.ja.md | 4 ++-- README.md | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 09fdd46..0d55c1e 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -2,12 +2,12 @@ ## Unreleased -パネルに「AI の学習に協力する」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるために、目の録画を開発者へ送る手伝いを、手順どおりに進められます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ始めます。 +パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。 -- 「AI の学習に協力する」: 最初の画面で、録るもの(目のカメラの映像。約 10.5 GB。送るまでは Frame の中だけ)、使いみち(半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、要る空き容量と今の空きを見せます。[協力する]で `~/eyecam/contrib_日時/` と `info.txt`(アプリの版、同意した時刻、ステップごとのプロトコル・フォルダ・大きさ・かぶり直しをどう知ったか。個人の情報は入れない)を作ります -- そのあと 4 回の録画(チップで進み具合を表示): eyecam の光なしの撮り方(`widen_nolight`、約 1 分)を 3 回と、ふだん使い 3 分(`free`。そのあいだにすることを画面に出す)。ふだん使いをもう 1 回は任意です。2 回目からは、始める前に HMD をいったん外してかぶり直すように頼み、それを eyecam-rec の状態から自動で見分けます(目の映像が止まって戻った・近接センサー・Frame がスリープした)。見分けられない HMD のために、頼んでから 20 秒たつと[かぶり直した]を押せます -- 録画は目の撮影と同じように進み(ビープ・ステップ・[中止])、終わったら協力のフォルダに移します。途中で止まった・失敗したものは `aborted/`(まとめるファイルには入れない)に入れ、そのステップを録り直せます。進み具合は `info.txt` に残るので、パネルや Frame を起動し直しても続きから(録画の途中でも)できます -- 最後に合計の大きさとフォルダ、[1 つのファイルにまとめる](zstd があれば `.tar.zst`、なければ `.tar`。空き容量を確かめてから、低い優先度で裏でまとめる。元の録画は残す)、送ったあと用の[この録画を消す](確認つき)を出します +- 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります +- 録画は `~/eyecam/contrib/` にたまります。`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろったものだけを移し(足りない・途中で止まったものは `aborted/` に入れて撮り直せる)、いちばん新しい校正結果の写しを隣に置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません +- 終わりの画面([録ったもの]からも)に、本数と合計の大きさ、フォルダ、送り方、[1 つのファイルにまとめる](`zstd -6` で `.tar.zst`。Frame の目の映像で 0.58 倍だった。zstd が無ければ `.tar`。空き容量を確かめてから、低い優先度で裏でまとめる。元の録画は残す)、[録画を消す](確認つき)を出します +- eyecam: 新しい `protocol_contrib.txt`。`look_left`(長 2 回、高→低)と `look_right`(長 2 回、低→高)に専用のビープ(今までは細めと同じ音だった)。status.json に `headset_off_count`(eyecam-rec が起動してから、目の映像が止まるか近接センサーで 10 秒以上外していて、またかぶった回数。サスペンドも数える時計で測る)と `started_unix` を足しました - `frameeyeosc-panel --dump-png … --fake-contrib 状態` で、それぞれの画面を描けます ## 0.8.2-beta.2 (2026-10-24) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b1f5a..03dd27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,14 @@ ## Unreleased -New in the panel: "Help train the AI" (Advanced › Debug tools, once the eye cameras are set up) guides you through recording your eyes for the developer, to train the "Half-shut check (AI)" on more people's eyes. It is entirely optional: it says first what is recorded and how it is used, and starts only when you agree. +New in the panel: "Record for squint training" (Advanced › Debug tools, once the eye cameras are set up) records your eyes for the developer with one press, to train the "Half-shut check (AI)" on more people's eyes. It is entirely optional: it says first what is recorded and how it is used, and records only after you agree. -日本語: パネルに「AI の学習に協力する」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるために、目の録画を開発者へ送る手伝いを、手順どおりに進められます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ始めます。 +日本語: パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。 -- "Help train the AI": a first screen says what is recorded (eye-camera video, about 10.5 GB, staying on the Frame until you send it), how it is used (only to tune the half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and the free space against what it needs; "I'll help" makes `~/eyecam/contrib_/` with an `info.txt` (the app version, the consent's time, each step's protocol, folder, size and how its re-wear was known; no personal data). -- Then four recordings, shown as chips: eyecam's protocol without light (`widen_nolight`, about 1 minute) three times, and 3 minutes of normal use (`free`, with what to do during it on screen); a second normal use is optional. Before each one after the first, it asks you to take the headset off and put it back on, and notices that by itself from eyecam-rec's status (the eye video stopping and coming back, the proximity sensor, or the Frame sleeping); "I put it back on" can be pressed 20 s after it asks, for a headset where that isn't seen. -- Each recording runs as the eye capture does (beeps, steps, "Stop") and is moved into the contribution's folder once it has ended; one that stopped early or failed goes to its `aborted/` (never packed) and that step can be recorded again. The progress is kept in `info.txt`, so it carries on after the panel or the Frame restarts, also with a recording that was running. -- At the end: the total size and the folder, "Make one file" (a `.tar.zst` through zstd, or a `.tar` without it, packed in the background at a low priority after checking the free space; the recordings stay), and "Delete these recordings" (after a confirmation) for once it has been sent. +- "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (privately, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). +- The recordings gather in `~/eyecam/contrib/`, each moved there once it has `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` (one without them, or stopped early, goes to `aborted/` and can be recorded again), with a copy of the newest calibration result beside it. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. +- The end's screen (also from "Recordings") shows how many there are and their size, the folder, how to send it, "Make one file" (a `.tar.zst` through `zstd -6`, measured on the Frame's eye video at 0.58 of the size; a `.tar` without zstd; packed in the background at a low priority after checking the free space; the recordings stay) and "Delete the recordings" (after a confirmation). +- eyecam: new `protocol_contrib.txt`; new beeps for `look_left` (2 long, high then low) and `look_right` (2 long, low then high), which had the squint's beep; status.json's new `headset_off_count` (the times the headset was taken off for 10 s or more and worn again since eyecam-rec started, by the eye video stopping or the proximity sensor, on a clock that counts suspend) and `started_unix`. - `frameeyeosc-panel --dump-png … --fake-contrib STATE` draws each of its screens. ## 0.8.2-beta.2 (2026-10-24) diff --git a/README.ja.md b/README.ja.md index 46a1372..aa36445 100644 --- a/README.ja.md +++ b/README.ja.md @@ -133,7 +133,7 @@ sudo は要りません。全部ホームフォルダ(`~/.local/bin`、`~/.loc - 目を合わせる: ボタン 1 つで正面・上の幅・まぶたを約 20 秒で合わせる([目を合わせる](#目を合わせる) を参照)、[真ん中だけ合わせ直す](真ん中の点 1 つ)、被ったときに自動で合わせ直すもの(「被ったとき」: 何もしない・真ん中だけ)、結果と[元に戻す]、「細かく直す」の中で値を手で直す(閉じているあいだは、合わせ方の箱が出ます) - まぶた: いちばん上の切り替えで 4 ページ(初めては「見た目」、あとは最後に開いたページ。パネルを起動し直しても開きます)。「見た目」: いまのまぶたがどこから来ているか(目のカメラ・片目だけ目のカメラ・Valve の値。カメラのオン / オフは「基本」タブで)、「見開き」(する / しない、`widen`。しないと、カメラがあってもなくても、まぶたはふつうに開いた所より上に行かない。閉じる・細めはそのまま。そのあいだ下のスライダーは灰色)、目のカメラを使っているときは「見開きの出やすさ」(鈍い〜敏感のスライダー 1 本。カメラの目にはその感度、Valve の値の目には目を合わせた目の 4 段階(しない / 控えめ / ふつう / 出やすい、`lid_widen`))、目のカメラを使っているときは「半目の読み直し(AI)」(する / しない。eyecam のまぶたの拒否権で、既定はしない。横を見たときなどに開いた目が半目に読まれたら、小さな AI が見直して開いた目に戻す。軽い細めも開いた目に戻ることがある。まだ試しの機能で、作った人の目だけで学習している)、まぶたのなめらかさ(弱 / 中 / 強)、左右をそろえる強さ(`lid_sync`)。「まばたき・細め」: まばたきを届ける(閉じたまま保つ時間)、まばたきを両目でそろえる(`blink_sync_below`)、目のカメラを使っているときは細めたときのまぶたの下限(`camera_lid_floor`。効くのはカメラの目があるとき)。「細かく直す」: 自動キャリブレーションと覚えた値、左右の倍率、左右の今の開き具合の上に重ねた 4 つの目盛り(目を閉じたり見開いたりしながら合わせる。③④は目を合わせた目とカメラの目では灰色)、「ほぼ開いてたら全開にする」(`lid_open_snap`)、なめらかさの 2 つの値。「眉」: 「見開きで眉も上げる」(する / しない、`brow_from_widen`。初めはしない。カメラは眉を見ていないので、アバターの眉は見開きについていく。「見開き」が「しない」でも上がる)と、「する」のあいだだけ「眉の上がり方」(`brow_strength`、弱い〜強い) - 目のカメラ: eyecam が動いているあいだ出ます。準備を始める前は「目のカメラでできること」と[はじめる(約 2 分)]、押すと準備のチェックリスト([目のカメラ](#目のカメラ))。済んだあとは、いまの状態(オン / オフは「基本」タブで)とその下の[普段の目を覚え直す](約 30 秒で普段の目を覚え直す。そのあいだも今までの値でまぶた・見開き・細めが動き、覚え終わったら新しい値に移る。校正の値はそのまま)、違和感があるときの「目のカメラの校正(18秒)」、目を細めた動きも送りたいときの「ユーザー校正(最初に 1 回)」、こんなときはどうするか -- 詳細: いちばん上の切り替えで「バージョン」「困ったとき」「動作確認」「ファイル」の 4 ページ(初めては「バージョン」、そのあとは最後に開いたページ。パネルを起動し直しても)。「バージョン」: 版の表示と更新の確認・更新(自動の確認のオン / オフも)と「更新履歴」(版ごとの要約と変更点、新しい順。開くのは 1 つずつで、スティックか右の上下の山形のボタンでスクロール)。「困ったとき」: 困ったときにスクショを送る「診断」のページ([うまく動かないとき](#うまく動かないとき)を参照)と、校正と目合わせの最近の記録(下を参照)。「動作確認」: 視線の点を表示と点の距離、目のログの記録(下を参照)、eyecam が動いているあいだは「開発用」の「目の撮影」(目の処理を調整するための目のカメラの録画で、目の映像が `~/eyecam/rec_…/` に残ります。1 回で約 2.5 GB。[panel/README.md](panel/README.md) を参照)と、目のカメラの準備が済んだら「AI の学習に協力する」(「半目の読み直し(AI)」をいろんな人の目で学習させるために、開発者へ送る目の録画。任意です。HMD をかぶり直すたびに約 1 分の決まった動きを 3 回と、ふだん使い 3 分。約 10.5 GB で、送って消すまで `~/eyecam/contrib_…/` に残ります。始める前に何を録ってどう使うかを見せ、送りやすいように 1 つのファイルにまとめられます)。「ファイル」: ファイルの場所、本体の PID、コマンドで固定中の項目 +- 詳細: いちばん上の切り替えで「バージョン」「困ったとき」「動作確認」「ファイル」の 4 ページ(初めては「バージョン」、そのあとは最後に開いたページ。パネルを起動し直しても)。「バージョン」: 版の表示と更新の確認・更新(自動の確認のオン / オフも)と「更新履歴」(版ごとの要約と変更点、新しい順。開くのは 1 つずつで、スティックか右の上下の山形のボタンでスクロール)。「困ったとき」: 困ったときにスクショを送る「診断」のページ([うまく動かないとき](#うまく動かないとき)を参照)と、校正と目合わせの最近の記録(下を参照)。「動作確認」: 視線の点を表示と点の距離、目のログの記録(下を参照)、eyecam が動いているあいだは「開発用」の「目の撮影」(目の処理を調整するための目のカメラの録画で、目の映像が `~/eyecam/rec_…/` に残ります。1 回で約 2.5 GB。[panel/README.md](panel/README.md) を参照)と、目のカメラの準備が済んだら「細めの学習用に撮る」(「半目の読み直し(AI)」をいろんな人の目で学習させるために、開発者へ送る目の録画。任意です。ボタン 1 つで約 1 分半〔閉じる・細める・見開く・上下左右を見る。1 本 約 2.7 GB〕。初めてのときだけ同意と、眼鏡・度付きインサートを使っているかを聞きます。送って消すまで `~/eyecam/contrib/` にたまり、送りやすいように 1 つのファイルにまとめられます)。「ファイル」: ファイルの場所、本体の PID、コマンドで固定中の項目 - 記録: パネルから始めた目のカメラの校正と、目合わせ(被ったときに自分で始めた合わせ直しも)のたびに、`~/.local/state/frameeyeosc/reports/` に小さなフォルダを 1 つ残します(新しい 10 件だけ): `summary.json`(何をして、どう終わり、なぜか。版、診断コード、条件: ダッシュボードを開いていたか、カメラと視線トラッカーの速さ、かぶってから何秒か)、`status.jsonl`(その間 1 秒ごとの本体と eyecam の状態)、`logs.txt`(始まる 15 秒前から終わりまでの、本体・パネル・eyecam のジャーナルと Valve のアイトラッカーのログを時刻順に。300 KB まで)、校正なら `calib_result.json`(その数値。目の画像は入れません)、`report.txt`。「困ったとき」に新しい 3 件([すべての記録]で全部)、[見る]で理由・流れ・ファイルを開きます。目合わせや校正が失敗した画面にも「ログを記録したよ」と[記録を見る]が出ます。SSH からは `.local/bin/frameeyeosc-panel --report latest` でいちばん新しい記録の `report.txt` が出ます(`--report list` で一覧、`--report <フォルダ名>` でその記録。SteamVR が無くても動きます)。どこにも送りません パネルは `config.json` を書き、状態ファイルを読みます。既定の言語を決めるために、起動時に 1 回だけ Steam の `~/.steam/registry.vdf` の `language` の行も読みます(読むだけ)。更新には `~/.local/share/frameeyeosc/frame-update.sh` を使います(上を参照)。閉じても、終了しても、入れていなくても frameeyeosc は送り続けます。ダッシュボードで開いていない間は何も描きません。そのとき読むのは、更新の確認を動かすほかは、更新の状態ファイル(`~/.cache/frameeyeosc/update-state.json`)を 1 秒に 2 回ほどだけです。ただし目を合わせている間は、終わるまで状態ファイルも読み、点を出します(ダッシュボードが開いていても閉じていても)。デバッグ用の視線の点をオンにしている間も、そのソケットで受け取り、サンプルが届いている間は 1 秒に約 90 回点を動かします。このときは状態ファイルも読み、`config.json` が変わったかを 1 秒に 10 回確かめます。「被ったとき」が「何もしない」以外で視線を合わせてあるときも、ヘッドセットを被ったのに気づくため、ダッシュボードを閉じている間 0.5 秒ごとに状態ファイルを読みます。目のプレビューを開いている間は、視線の点のソケットで受け取り(点は出しません)、1 秒に約 30 回描き直します。カメラ映像がオンなら、eyecam に 1 秒ごとに映像を頼み、それを読みます。「SteamVR と一緒に起動」は、パネルの systemd ユーザーサービス(`frameeyeosc-panel.service`)を有効 / 無効にします。ビルド方法や確認用のオプションは [panel/README.md](panel/README.md) にあります。 @@ -499,7 +499,7 @@ eyecam-rec は、受け取ったバッファも Valve の目のデータも読 - パネルが `$XDG_RUNTIME_DIR/frameeyeosc/` に書くもの(メモリの上にあり、再起動で消える): 目を合わせるときの音のファイル `sounds/`(起動時に書く) - パネルが `~/.local/state/frameeyeosc/` に書くもの: 校正と目合わせの記録 `reports/`(新しい 10 件。[パネル](#パネル)を参照)と、`panel.json`(詳細タブ・まぶたタブ・視線タブで最後に開いたページ、視線の倍率の鍵、直近 5 回の全部合わせの上の幅)。記録を作るときは、その前後のジャーナル(`journalctl --user`、読むだけ)と Valve の `~/.local/share/Steam/logs/eyetracking.txt` を読みます - - eyecam(目のカメラ)が書くもの: 設定と校正 `~/.config/eyecam/`(`settings.json` に見開きの感度、`calib.json` に校正で測った値)、メモリの上(再起動で消える)の決まった場所: `/run/user/1000/eyecam/` に状態ファイル・操作用のソケット・frameeyeosc が読む目の値(と、パネルの目のプレビューでカメラ映像を出している間だけ、その映像。下を参照)、そのフォルダーの隣に eyecam-grab がバッファを渡すソケット `/run/user/1000/eyecam.sock`。systemd のジャーナル(`journalctl --user -u eyecam`)に、30 秒ごとに 1 行(処理したフレームの数、1 フレームの時間、左右の瞳孔のふだんの差)と、校正のたびに測った値の報告。校正では目の映像を残しません(開発者モードを除く)。校正のたびに(準備のときもあとからも、失敗したり止めたりしても)、`~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/` に小さなテキストのファイルだけ(1 MB ほど: 結果、フレームごとに出した値、フレームの時刻、Valve の値)を残します。eyecam の開発者モード(`~/.config/eyecam/settings.json` の `"dev": true`。手で書き換えるもので、最初はオフ)のときだけ、その 18 秒の目のカメラの映像も残します(1 回で約 0.5 GB)。詳細タブの開発用の「目の撮影」は、いつも目の映像を `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` に残します(1 回で約 2.5 GB。始める前にパネルにも出ます)。詳細タブの「AI の学習に協力する」は、最初の画面で同意したときだけ、録画(目の映像。約 10.5 GB)と `info.txt`(アプリの版・時刻・プロトコル・フォルダ名・大きさ。個人の情報は入れない)を `~/eyecam/contrib_YYYY-MM-DD_HH-MM-SS/` に、まとめた 1 つのファイルも同じ場所に残します。パネルはどれもどこにも送りません(ファイルを渡すかはあなたが決めます)。[この録画を消す]でフォルダごと消せます。目のカメラを使っていてパネルの目のログを記録している間([開発](#開発)を参照)と、自分で eyecam に `driftlog on` を送ったとき(調べもの用)だけ、記録が終わるか `driftlog off` まで、数字だけ(映像なし)を `~/eyecam/drift/` に書きます(目のログと一緒なら `eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv`、手で送ったら `drift_YYYY-MM-DD_HH-MM-SS.csv`): 目ごとに 1 秒 1 行(瞳を見つけた位置、比べている基準の値、出した値、Valve の開き具合と視線の上下。1 時間で約 1.3 MB、1 ファイル 50 MB まで)。どれもヘッドセットの外には出しません。要らなくなったら消してください + - eyecam(目のカメラ)が書くもの: 設定と校正 `~/.config/eyecam/`(`settings.json` に見開きの感度、`calib.json` に校正で測った値)、メモリの上(再起動で消える)の決まった場所: `/run/user/1000/eyecam/` に状態ファイル・操作用のソケット・frameeyeosc が読む目の値(と、パネルの目のプレビューでカメラ映像を出している間だけ、その映像。下を参照)、そのフォルダーの隣に eyecam-grab がバッファを渡すソケット `/run/user/1000/eyecam.sock`。systemd のジャーナル(`journalctl --user -u eyecam`)に、30 秒ごとに 1 行(処理したフレームの数、1 フレームの時間、左右の瞳孔のふだんの差)と、校正のたびに測った値の報告。校正では目の映像を残しません(開発者モードを除く)。校正のたびに(準備のときもあとからも、失敗したり止めたりしても)、`~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/` に小さなテキストのファイルだけ(1 MB ほど: 結果、フレームごとに出した値、フレームの時刻、Valve の値)を残します。eyecam の開発者モード(`~/.config/eyecam/settings.json` の `"dev": true`。手で書き換えるもので、最初はオフ)のときだけ、その 18 秒の目のカメラの映像も残します(1 回で約 0.5 GB)。詳細タブの開発用の「目の撮影」は、いつも目の映像を `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` に残します(1 回で約 2.5 GB。始める前にパネルにも出ます)。詳細タブの「細めの学習用に撮る」は、最初の画面で同意したときだけ、録画(目の映像。1 本 約 2.7 GB)を `~/eyecam/contrib/` に、`info.txt`(アプリの版・時刻・そのときの設定・眼鏡やインサートの答え・録画のあいだに HMD を外したか・フォルダ名・大きさ。個人の情報は入れない)と、録画ごとの校正結果の写し、まとめた 1 つのファイルと一緒に残します。パネルはどれもどこにも送りません(渡すかはあなたが決めます。公開の場所には上げないでください)。[録画を消す]でフォルダごと消せます。目のカメラを使っていてパネルの目のログを記録している間([開発](#開発)を参照)と、自分で eyecam に `driftlog on` を送ったとき(調べもの用)だけ、記録が終わるか `driftlog off` まで、数字だけ(映像なし)を `~/eyecam/drift/` に書きます(目のログと一緒なら `eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv`、手で送ったら `drift_YYYY-MM-DD_HH-MM-SS.csv`): 目ごとに 1 秒 1 行(瞳を見つけた位置、比べている基準の値、出した値、Valve の開き具合と視線の上下。1 時間で約 1.3 MB、1 ファイル 50 MB まで)。どれもヘッドセットの外には出しません。要らなくなったら消してください 上の eyecam の校正のファイル・録画・ドリフトの記録・ジャーナルの行と、パネルの記録(その `status.jsonl` には、校正や目合わせの間 1 秒ごとの本体と eyecam の状態が目の値ごと入ります)のほかは、目のデータは保存しません。ただし目を合わせたときに測った値(視線の平均の向きとばらつき、目ごとの開き具合の平均)は、1 回ごとに 1 行 systemd のジャーナルに残り、まぶたの読んだ値は `config.json` に残ります。最新の目の値は状態ファイルにありますが、これはメモリの上にあって本人しか読めず、1 秒に 10 回上書きされます。履歴は残しません - OSC は暗号化されないので、同じネットワーク上の他の機器から読める可能性があります diff --git a/README.md b/README.md index ba5beba..192361f 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ To go back, turn "Get test versions" off. Your test version stays installed unti - Eye fit: one button that fits straight ahead, the up range and the eyelids in about 20 seconds (see [Eye fit](#eye-fit)), "Re-center only" (the one dot in the middle), what to fit by itself when you put the headset on ("When put on": nothing or re-center), the result with "Reset", and the values by hand under "Fine-tune" (while it is folded, a box says how fitting works). - Eyelids: four pages under a switch at the top (the first time "Look", then the one you opened last, also after the panel restarts). "Look": where the eyelids come from now (the eye cameras, one eye camera and Valve's values, or Valve's values; the cameras are switched on the Basic tab), "Widening" On / Off (`widen`; Off: no eyelid goes above relaxed open, with the eye cameras or without, while blinks and squint stay; the slider below is greyed meanwhile), "How easily widening shows" while the eye cameras are in use (one slider from Dull to Sensitive: for eyes on the cameras it sets their widening sensitivity; for an eye on Valve's values it picks Off / Less / Normal / More (`lid_widen`) for eyes with an eye fit), "Half-shut check (AI)" On / Off while the eye cameras are in use (eyecam's eyelid veto, off by default: a small AI double-checks an open eye read as half shut, for example looking to the side, and keeps it open; it may also open up a light squint; experimental, trained on the developer's eyes only), eyelid smoothing (light / medium / strong), and how strongly both lids are synced (`lid_sync`). "Blinks & squint": making blinks visible (hold time), blinking both eyes together (`blink_sync_below`), and, while the eye cameras are in use, the lowest eyelid when narrowed (`camera_lid_floor`; it takes effect while an eye is on the cameras). "Fine-tune": the auto calibration and its learned values, per-eye scales, the four openness marks drawn over each eye's live openness (blink and open wide to set them; 3 and 4 greyed for fitted eyes and eyes on the cameras), "Treat nearly open as open" (`lid_open_snap`), and the two smoothing values. "Brows": "Widen raises brows" On / Off (`brow_from_widen`, off at first; the cameras don't see brows, so the avatar's brows follow the widening, also with "Widening" Off) and, while it is on, "Brow raise strength" (`brow_strength`, Weak to Strong). - Eye cameras: shown while eyecam runs. Before the setup has begun, what the cameras add and "Get started (about 2 min)", which opens the setup checklist (see [Eye cameras](#eye-cameras)); once set up, what drives the eyelids now (switched on or off on the Basic tab) with "Relearn my eyes" under it (learns your relaxed eyes again in about 30 s, keeping the calibration; the eyelids, widening and squint keep working from the old values meanwhile, then move over to the new ones), "Calibrate the cameras (18 s)" for when something feels off, the optional "User calibration (once)" for squints, and what to do when. -- Advanced: four pages under a switch at the top, "Version", "Having trouble", "Debug tools" and "Files" (the first time "Version", then the one you opened last, also after the panel restarts). "Version": the version with checking for and installing updates (and the automatic check on or off) and "Version history" (each version's summary and changes, newest first; one opens at a time, and the thumbstick or the up / down chevrons on the right scroll). "Having trouble": the Diagnostics page to send as a screenshot when stuck (see [Troubleshooting](#troubleshooting)), and the recent records of calibrations and eye fits (see below). "Debug tools": showing gaze dots and their distance, recording the eye log (see below), and while eyecam runs the "Developer" eye-camera recording for tuning the eye processing, which keeps the eye video in `~/eyecam/rec_…/` (about 2.5 GB each time; see [panel/README.md](panel/README.md)), and, once the eye cameras are set up, "Help train the AI": optional recordings of your eyes for the developer, to train the "Half-shut check (AI)" on more people's eyes (three guided recordings of about a minute, each after taking the headset off and putting it back on, and three minutes of normal use; about 10.5 GB, kept in `~/eyecam/contrib_…/` until you send it and delete it). It says what is recorded and how it is used before anything starts, and can pack it into one file to share. "Files": file locations, frameeyeosc's PID, options locked by the command line. +- Advanced: four pages under a switch at the top, "Version", "Having trouble", "Debug tools" and "Files" (the first time "Version", then the one you opened last, also after the panel restarts). "Version": the version with checking for and installing updates (and the automatic check on or off) and "Version history" (each version's summary and changes, newest first; one opens at a time, and the thumbstick or the up / down chevrons on the right scroll). "Having trouble": the Diagnostics page to send as a screenshot when stuck (see [Troubleshooting](#troubleshooting)), and the recent records of calibrations and eye fits (see below). "Debug tools": showing gaze dots and their distance, recording the eye log (see below), and while eyecam runs the "Developer" eye-camera recording for tuning the eye processing, which keeps the eye video in `~/eyecam/rec_…/` (about 2.5 GB each time; see [panel/README.md](panel/README.md)), and, once the eye cameras are set up, "Record for squint training": an optional one-press recording of your eyes for the developer (about 1.5 minutes: closing, squinting, widening and looking around, about 2.7 GB each), to train the "Half-shut check (AI)" on more people's eyes. It asks for your consent and whether you wear glasses or prescription inserts the first time, keeps the recordings in `~/eyecam/contrib/` until you send them privately and delete them, and can pack them into one file. "Files": file locations, frameeyeosc's PID, options locked by the command line. - Records: every eye-camera calibration started from the panel and every eye fit (also the re-center run by itself when the headset is put on) leaves a small folder in `~/.local/state/frameeyeosc/reports/` (the newest 10 are kept): `summary.json` (what ran, how it ended and why, the versions, the diagnostic code, the conditions: dashboard open or closed, camera and eye-tracker rates, how long since the headset was put on), `status.jsonl` (frameeyeosc's and eyecam's status once a second while it ran), `logs.txt` (frameeyeosc's, the panel's and eyecam's journal and Valve's eye-tracker log from 15 seconds before it to its end, in time order, at most 300 KB), for calibrations `calib_result.json` (its numbers, never eye images) and `report.txt`. "Having trouble" lists the newest three ("All records" lists them all); "View" opens one: the reason, what happened in order, and the files. A failed fit or calibration says its log was saved, with "View record". Over SSH, `.local/bin/frameeyeosc-panel --report latest` prints the newest record's `report.txt` (`--report list` lists them, `--report ` prints one); it works without SteamVR. Nothing is sent anywhere. The panel writes `config.json` and reads the status file. To pick its default language, it also reads the `language` line of Steam's `~/.steam/registry.vdf` once at startup (read only). For updates it runs `~/.local/share/frameeyeosc/frame-update.sh` (see above). Closing it, quitting it, or not installing it doesn't stop frameeyeosc. While it isn't open on the dashboard it draws nothing. Besides running the update check, the only thing it reads then is the update state file (`~/.cache/frameeyeosc/update-state.json`), about twice a second. The exceptions are an eye fit (it then also reads the status file and shows the dot, with the dashboard open or closed, until the fit is over) and the debug gaze dots while they're switched on (it then listens on their socket and moves the dots about 90 times a second while samples arrive, and reads the status file and checks `config.json` for changes 10 times a second), and "When put on" while the gaze is fitted (it then reads the status file every 0.5 s while the dashboard is closed, to notice the headset being put on). While the eye preview is open it listens on the gaze dots' socket (without showing the dots) and redraws about 30 times a second; with its camera picture on, it asks eyecam for the pictures every second and reads them. Its "Start with SteamVR" switch enables or disables its systemd user unit (`frameeyeosc-panel.service`). Build notes and debugging options are in [panel/README.md](panel/README.md) (Japanese). @@ -499,7 +499,7 @@ To update again later, use the panel's update button or the usual install comman - from the panel, in `$XDG_RUNTIME_DIR/frameeyeosc/` (in memory, gone after a reboot): the eye fit's sound files in `sounds/`, written at start - from the panel, in `~/.local/state/frameeyeosc/`: the records of calibrations and eye fits in `reports/` (the newest 10; see [Panel](#panel)) and in `panel.json` the last page of the Advanced, Eyelids and Gaze tabs, the gaze scale's locks and the up ranges of the last 5 full eye fits. To make a record the panel reads the journal (`journalctl --user`, read only) and Valve's `~/.local/share/Steam/logs/eyetracking.txt` for the minute around it - - from eyecam (the eye cameras): its settings and calibration in `~/.config/eyecam/` (`settings.json` with the widening sensitivity, `calib.json` with what the calibrations measured), and in memory (gone after a reboot) at fixed paths: `/run/user/1000/eyecam/` with its status file, its control socket and the eye values frameeyeosc reads (and, only while the panel's eye preview shows the camera picture, the preview's pictures: see below), and next to that folder `/run/user/1000/eyecam.sock`, the socket eyecam-grab hands the buffers to. In the systemd journal (`journalctl --user -u eyecam`), every 30 seconds one line with how many frames it worked out, how long each took and the usual difference between the left and right pupil, and after each calibration a report of what it measured. Calibrations keep no eye video: each one (the setup's and later ones, also one that failed or was stopped) leaves only small text files in `~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/`, about 1 MB: the result, the values worked out for each frame, the frame times and Valve's readings. Only in eyecam's developer mode (`"dev": true` in `~/.config/eyecam/settings.json`, set by hand; off by default) do they keep the 18 seconds of eye-camera video too (about 0.5 GB each). The developer eye recording on the Advanced tab always keeps eye video, in `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` (about 2.5 GB each time); the panel says so before it starts. "Help train the AI" on the Advanced tab, only after you agree on its first screen, keeps its recordings (eye video, about 10.5 GB) with an `info.txt` (the app version, times, protocols, folder names and sizes; nothing personal) in `~/eyecam/contrib_YYYY-MM-DD_HH-MM-SS/`, and the one file it packs them into there too; the panel sends none of it anywhere (sharing the file is up to you), and its "Delete these recordings" removes the folder. While the panel's eye log records with the eye cameras in use (see [Development](#development)), and if you send eyecam `driftlog on` yourself (an investigation tool), it writes numbers only, no pictures, to `~/eyecam/drift/` (`eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv` beside the eye log, `drift_YYYY-MM-DD_HH-MM-SS.csv` by hand): one line per eye per second (where it found the pupil, the levels it compares with, the values it worked out and Valve's openness and pitch; about 1.3 MB an hour, at most 50 MB a file) until the recording ends or `driftlog off`. Nothing of it leaves the headset; delete them when you no longer need them + - from eyecam (the eye cameras): its settings and calibration in `~/.config/eyecam/` (`settings.json` with the widening sensitivity, `calib.json` with what the calibrations measured), and in memory (gone after a reboot) at fixed paths: `/run/user/1000/eyecam/` with its status file, its control socket and the eye values frameeyeosc reads (and, only while the panel's eye preview shows the camera picture, the preview's pictures: see below), and next to that folder `/run/user/1000/eyecam.sock`, the socket eyecam-grab hands the buffers to. In the systemd journal (`journalctl --user -u eyecam`), every 30 seconds one line with how many frames it worked out, how long each took and the usual difference between the left and right pupil, and after each calibration a report of what it measured. Calibrations keep no eye video: each one (the setup's and later ones, also one that failed or was stopped) leaves only small text files in `~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/`, about 1 MB: the result, the values worked out for each frame, the frame times and Valve's readings. Only in eyecam's developer mode (`"dev": true` in `~/.config/eyecam/settings.json`, set by hand; off by default) do they keep the 18 seconds of eye-camera video too (about 0.5 GB each). The developer eye recording on the Advanced tab always keeps eye video, in `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` (about 2.5 GB each time); the panel says so before it starts. "Record for squint training" on the Advanced tab, only after you agree on its first screen, keeps its recordings (eye video, about 2.7 GB each) in `~/eyecam/contrib/` with an `info.txt` (the app version, times, the settings then, your answer about glasses or inserts, whether the headset was taken off between recordings, folder names and sizes; nothing personal), a copy of the calibration result next to each, and the one file it packs them into; the panel sends none of it anywhere (sharing it is up to you, never in a public place), and its "Delete the recordings" removes the folder. While the panel's eye log records with the eye cameras in use (see [Development](#development)), and if you send eyecam `driftlog on` yourself (an investigation tool), it writes numbers only, no pictures, to `~/eyecam/drift/` (`eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv` beside the eye log, `drift_YYYY-MM-DD_HH-MM-SS.csv` by hand): one line per eye per second (where it found the pupil, the levels it compares with, the values it worked out and Valve's openness and pitch; about 1.3 MB an hour, at most 50 MB a file) until the recording ends or `driftlog off`. Nothing of it leaves the headset; delete them when you no longer need them Apart from eyecam's calibration files, recordings, drift logs and journal lines above, and the panel's records (their `status.jsonl` keeps frameeyeosc's and eyecam's status, with the eye values, once a second while a calibration or fit ran), no eye data is stored, except that each eye fit measurement (an average gaze direction, how much it spread, and each eye's average openness) is logged as one line to the systemd journal, and the eyelid readings of a fit are kept in `config.json`. The latest eye values are in the status file, which is in memory, readable only by you, and overwritten ten times a second; no history is kept. - The OSC messages are unencrypted, so other devices on the same network could read them. From 5f843bd4850bd48534000922d2967c33cada28f6 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 12:41:44 +0900 Subject: [PATCH 38/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E3=80=8C?= =?UTF-8?q?=E7=B4=B0=E3=82=81=E3=81=AE=E5=AD=A6=E7=BF=92=E7=94=A8=E3=81=AB?= =?UTF-8?q?=E6=92=AE=E3=82=8B=E3=80=8D=E3=81=AE=E3=82=B7=E3=83=BC=E3=83=88?= =?UTF-8?q?=E3=81=AB=E7=9B=AE=E7=9A=84=E3=81=AE=201=20=E8=A1=8C=E3=81=A8?= =?UTF-8?q?=E3=80=81=E3=81=BE=E3=81=A8=E3=82=81=E3=81=9F=E3=83=95=E3=82=A1?= =?UTF-8?q?=E3=82=A4=E3=83=AB=E3=81=8C=20.tar.zst=EF=BC=887-Zip=20?= =?UTF-8?q?=E3=81=AA=E3=81=A9=E3=81=A7=E9=96=8B=E3=81=91=E3=82=8B=EF=BC=89?= =?UTF-8?q?=E3=81=A0=E3=81=A8=E3=81=84=E3=81=86=E4=B8=80=E8=A8=80=E3=82=92?= =?UTF-8?q?=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 2 +- CHANGELOG.md | 2 +- panel/README.md | 6 +++--- panel/src/i18n.cpp | 6 +++++- panel/src/i18n.h | 2 ++ panel/src/panel.cpp | 4 +++- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 0d55c1e..189441d 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -6,7 +6,7 @@ - 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります - 録画は `~/eyecam/contrib/` にたまります。`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろったものだけを移し(足りない・途中で止まったものは `aborted/` に入れて撮り直せる)、いちばん新しい校正結果の写しを隣に置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません -- 終わりの画面([録ったもの]からも)に、本数と合計の大きさ、フォルダ、送り方、[1 つのファイルにまとめる](`zstd -6` で `.tar.zst`。Frame の目の映像で 0.58 倍だった。zstd が無ければ `.tar`。空き容量を確かめてから、低い優先度で裏でまとめる。元の録画は残す)、[録画を消す](確認つき)を出します +- 終わりの画面([録ったもの]からも)に、本数と合計の大きさ、フォルダ、送り方、[1 つのファイルにまとめる](`zstd -6` で `.tar.zst`。Frame の目の映像で 0.58 倍だった。7-Zip などで開ける。zstd が無ければ `.tar`。空き容量を確かめてから、低い優先度で裏でまとめる。元の録画は残す)、[録画を消す](確認つき)を出します - eyecam: 新しい `protocol_contrib.txt`。`look_left`(長 2 回、高→低)と `look_right`(長 2 回、低→高)に専用のビープ(今までは細めと同じ音だった)。status.json に `headset_off_count`(eyecam-rec が起動してから、目の映像が止まるか近接センサーで 10 秒以上外していて、またかぶった回数。サスペンドも数える時計で測る)と `started_unix` を足しました - `frameeyeosc-panel --dump-png … --fake-contrib 状態` で、それぞれの画面を描けます diff --git a/CHANGELOG.md b/CHANGELOG.md index 03dd27d..dcbac7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ New in the panel: "Record for squint training" (Advanced › Debug tools, once t - "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (privately, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). - The recordings gather in `~/eyecam/contrib/`, each moved there once it has `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` (one without them, or stopped early, goes to `aborted/` and can be recorded again), with a copy of the newest calibration result beside it. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. -- The end's screen (also from "Recordings") shows how many there are and their size, the folder, how to send it, "Make one file" (a `.tar.zst` through `zstd -6`, measured on the Frame's eye video at 0.58 of the size; a `.tar` without zstd; packed in the background at a low priority after checking the free space; the recordings stay) and "Delete the recordings" (after a confirmation). +- The end's screen (also from "Recordings") shows how many there are and their size, the folder, how to send it, "Make one file" (a `.tar.zst` through `zstd -6`, measured on the Frame's eye video at 0.58 of the size, which 7-Zip and the like open; a `.tar` without zstd; packed in the background at a low priority after checking the free space; the recordings stay) and "Delete the recordings" (after a confirmation). - eyecam: new `protocol_contrib.txt`; new beeps for `look_left` (2 long, high then low) and `look_right` (2 long, low then high), which had the squint's beep; status.json's new `headset_off_count` (the times the headset was taken off for 10 s or more and worn again since eyecam-rec started, by the eye video stopping or the proximity sensor, on a clock that counts suspend) and `started_unix`. - `frameeyeosc-panel --dump-png … --fake-contrib STATE` draws each of its screens. diff --git a/panel/README.md b/panel/README.md index cf70906..675f639 100644 --- a/panel/README.md +++ b/panel/README.md @@ -31,7 +31,7 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ | 目を合わせる | [目を合わせる](ヒント「正面・上の幅・まぶた・約 20 秒」。合わせたあとは[もう一度合わせる])と小さい[真ん中だけ合わせ直す]、「被ったとき」の行(何もしない / 真ん中だけ、`auto_recenter`。既定は真ん中だけ。0.7.6 までの `"tilt"` は `"center"` として読む)、今どうなっているか(始め方・結果[元に戻す]・止めた / 失敗の理由。被ったときの合わせ直しをダッシュボードが止めて、もう一度やるときは「ダッシュボードを閉じたら、もう一度合わせ直します」も)。目のカメラの校正中(`eyecam::calibrationUnderWay`: eyecam-rec が `calibrating`、目の映像を待っている間も。パネルが送って返事を待っている間も)は[目を合わせる]と[真ん中だけ合わせ直す]がグレーで、箱の見出しが「目のカメラの校正が終わってから使えます」。目を合わせている間はタブ全体が「点を見てください」と何をするか(動かない文字)と大きな[やめる]だけになり、左の列とタブの並びは暗くなる(押せるのはタブと[やめる]だけ。ほかのタブを押すと止まる)、[♪ 音を鳴らす: オン / オフ]、「細かく直す」(開閉。[視線 / まぶた]で切り替え。視線: 正面の位置・動く幅(左右・上・下)、真下で左右を止める角度。まぶた: 目ごとの読んだ値 閉じ / 上 / 正面 / 下 の − / +。閉じているあいだは、その下に「合わせ方」の箱: 被るたびは真ん中の点を 1 つ見るだけ(自動)、[目を合わせる]は最初と気になったときだけ、傾きは使わなくなったこと) | | まぶた | 見た目の調整を全部ここに(案E)。いちばん上の切り替えで 4 つの小タブ(詳細タブと同じ形。初めては「見た目」、あとは最後に開いたもの。パネルを起動し直しても `panel.json` の `lids_page` で覚えている)。「見た目」: 「いまのまぶた」の帯(目のカメラ(左・右)/ 目のカメラ(左)+ Valve(右)/ 目のカメラ(待機中)/ Valve の値。準備が済んでいれば右に「オン・オフは「基本」タブで」)、「見開き」の行(する / しない で `widen` を `SetBool`。カメラを使っていてもいなくてもいつも出す。ヒント「閉じ・細めはそのまま」。「しない」のあいだは下の「見開きの出やすさ」を灰色にして押せなくし、その下の 1 行を「「見開き」が「しない」なので、いまは使わないよ」に。目のカメラタブのいちばん下の文も「カメラで両目を見て、まぶたを送ってるよ(見開きはしない)」に、診断の「まぶた」の行には「· 見開きしない」を足す。`--fake-no-widen` で描ける)、目のカメラを使っているときだけ「見開きの出やすさ」のスライダー 1 本(緑の「カメラ」バッジ。鈍い ↔ 敏感。両目がカメラなら eyecam-rec の `widen_sensitivity` を `set widen_sensitivity` で送る。片目だけカメラなら同じく感度を送り、Valve の目には近い `lid_widen` の段階も一緒に書く。Valve の値なら `lid_widen` の 4 段階(しない / 控えめ / ふつう / 出やすい)で、押した所に近い止まり位置へ。目を合わせていない目があると下に「目を合わせていない目は、「細かく直す」の目盛りで決まるよ」。いまカメラの目が無く、ふつうに開いた目が 1.0 と読まれる SteamOS(`openness_saturated`)では、スライダーの代わりに 1 行: ヒント「この SteamOS では効かないよ」、「Valve の値だけだと見開きが出ないよ」と[目のカメラへ →](目のカメラタブがあるときだけ))、目のカメラを使っていて eyecam-rec の status.json に `lid_veto.on` があるときだけ「半目の読み直し(AI)」(する / しない。緑の「カメラ」バッジ。eyecam-rec に `set lid_veto on|off` を送り、選ばれているほうは status.json の `lid_veto.on` のまま〔panel.json には持たない〕。下に 3 行までの説明。`--fake-eyecam …:veto=on|off` で描ける)、まぶたのなめらかさ(弱 / 中 / 強。中 = 本体の既定値、どれにも合わなければカスタム)、左右をそろえる強さ(`lid_sync` のスライダー、0.05 刻み)。目のカメラを使っていないときは、いちばん下に点線の 1 行「目のカメラを使うと「見開きの出やすさ」が増えるよ」(押すと目のカメラタブ)。なので、カメラが無いと `lid_widen` はパネルから変えられない。「まばたき・細め」: まばたきを届ける(保持 ms の − / +)、まばたきを両目でそろえる(`blink_sync_below` の − / +、0 でオフ)、細めたときのまぶたの下限(`camera_lid_floor` の − / +、0.05 刻み、0 でオフ。目のカメラを使っているときだけ出す。緑の「カメラ」バッジ。カメラの目がまだないときは「目のカメラが目を見てるときに効くよ」と添える。使っていないときは点線の 1 行「目のカメラを使うと「細めたときのまぶたの下限」も使えるよ」)。それぞれ右に説明。「細かく直す」: いちばん上に目盛りの決まり方の 1 行、自動キャリブレーション(目を合わせていない目で、カメラでないとき)、左右の倍率、生の値の棒と 4 つの目盛り(③④は目を合わせた目とカメラのときは灰色)、ほぼ開いてたら全開にする(⑤。生の値の棒にも ⑤ の青い破線を出す。目を合わせた目は倍率をかけた位置、カメラが読んでいる目は薄く、0.75 のときは出さない。行の数字と − / + の縁も同じ青。`lid_open_snap` の − / +、送るまぶたの値で 0〜0.75 の 0.01 刻み、0.75 でオフ。いつも出す。右に説明。開いた値に対する割合だったころの 0.75 より大きい値は、起動時に一度だけ置き換える(`migrateLidOpenSnap`))、なめらかさの 2 つの値(止まっている時・速い動き。ページに収まるよう、それぞれの左に名前を置いた 1 行)。目のカメラタブの[まぶたへ →]は「見た目」を開く。「眉」: 「見開きで眉も上げる」(する / しない で `brow_from_widen` を `SetBool`。ヒント「カメラは眉を見ていないよ」。カメラを使っていてもいなくても、「見開き」が「しない」でもいつも出す。下に 3 行までの説明〔`\n` の所で改行〕)と、「する」のあいだだけ「眉の上がり方」(`brow_strength` のスライダー、弱い ↔ 強い、0.05 刻み。ヒント「アバターに合わせて」)。`--fake-brows` で「する」を描ける。`--lids-page look|blinks|fine|brows` で小タブを選んで描く(`--lid-marks` は fine と同じ) | | 目のカメラ | eyecam-rec が動いているあいだだけ出る(「詳細」の前。出る条件は下の「目の撮影」と同じ)。準備を始める前(準備が済んだことがなく、まだ ① か ② の段で、このパネルを起動してから[はじめる]を押していないとき。`eyecam::introShown`)は、「目のカメラでできること」(見開き・瞳孔の大きさ・Valve がまぶたを低く読んだときにカメラで確かめて直す)、1 回だけの準備と目の映像は Frame の外に出さないことの箱、[はじめる(約 2 分)]、「使わなくても、目の送信はふつうに動きます」。押したあと(と準備が進んでいるとき)は準備のチェックリスト(いまの段階は、満たしていない最初のもの。毎回 status.json とパスワードの確認だけで決める): ① パスワードを決める(SteamOS にパスワードが無いと分かっているときだけ。`steamos-passwd --has-password` を裏で数秒おきに確かめる。読むだけ。[Konsole で passwd を開く])② 道具を入れる([Konsole で開く]で `sudo $HOME/.local/lib/eyecam/install_grab.sh` が入力済みの Konsole を開くだけ。Enter とパスワードは本人で、パネルは sudo も passwd も実行しない。`auto_grab` が ok / trying / waiting_tracker / failed: … か、バッファを持っていれば入っている扱い。`auto_grab` が `too_old`(eyecam の安全のための下限より古く、起動されない)なら入っていない扱い。`grab_outdated` が true(更新で新しい道具が届いた)か too_old なら、見出しを「道具を入れ直す」、その下に「道具が新しくなったよ、入れ直してね」)③ 目の動きを覚える(カメラの映像は「届いてるよ」、`cam_fps` が毎秒 60 枚より少なければアクセント色で「届いてるよ(毎秒 15 枚・少なめ)」。目ごとに「見えてるよ」(status.json の `pupil_l` / `pupil_r` で瞳が 50 % 以上)・「映像はあるけど瞳が見つからない」(`locked` だけど 20 % 未満、赤)・「瞳がときどきしか見えない」(そのあいだ)・「見えてないよ」(`locked` が false)。`pupil_l` / `pupil_r` の無い古い eyecam-rec では今までどおり `locked` だけで決める。目の映像が届いているとき(`locked`)は[覚えはじめる]で `calib wear` を押せて、瞳が見つからない目があるときはその下に赤字で「HMD をかぶり直して」と出す。18 秒の間、ステップのチップ・残り秒・[やめる]をカードの中に出す。失敗したら eyecam-rec のメッセージ(数も入っている)の上に、メッセージが名前を出した目(「うまくいかなかった目」)を出す。見開きだけ取れなかったら[このまま進む][もう一度(18秒)]。片目だけ失敗して通ったとき(`calib_failed_eye` が L / R)は「左目は前の値を使うよ」(初めてなら「仮の値」)と目ごとの結果、メッセージ、[使いはじめる][もう一度(18秒)])④ 完了(「使いはじめる」)。セットアップ済みでも道具が無くなれば ② に戻り、直れば校正し直さずにふだんの画面へ。道具が古いだけなら ② には戻さず、ふだんの画面のいちばん上にお知らせのカードと[Konsole で開く](`grab_outdated`: 「道具が新しくなったよ、入れ直してね(今の道具のままでも動くよ)」、`too_old`: 赤いカードで「安全のため、道具を入れ直してね。入れ直すまでカメラは止まってて、まぶたは Valve の値だけで送ってるよ」)、左の列にも同じお知らせ(押すとこのタブ)。eyecam-rec が言わなくなれば自然に消える(`eyecam::toolState` / `toolNotice`。`--fake-eyecam …:outdated`、`waiting:auto=too_old:setup:outdated` で描く)。左の列には、準備が済むまで「次にやること・目のカメラ」、済んだ直後に緑の「目のカメラ:準備できたよ」。済んだあとのふだんの画面: いまの状態(両目・片目・Valve の値とその理由。ヒントは「オン・オフは「基本」タブで」。カメラのスイッチ `camera_lids` は「基本」タブに移した。オフのあいだの校正のボタンは「「基本」タブで目のカメラを「使う」にしてから」)、その下の行の右に[普段の目を覚え直す](英語は Relearn my eyes。左に「約 30 秒。そのあいだも今の値で動くよ」。eyecam-rec に `relearn` を送り、覚え始めがやり直しになって、いまの状態の右のピルが「普段の目を覚えてるよ(あと N 秒)」に戻る。覚えているあいだも、もう覚えていた目は前の値でまぶた・見開き・細めを送り続ける(`calib_state` の 4 はそのまま)。押せるのは `eyecam::relearnAllowed`: eyecam-rec の状態が新しく、idle・live・目の映像あり〔`locked`〕・`baseline` を書く eyecam-rec、`camera_lids` がオン、カメラがまぶたを動かしているか覚えている途中〔`relearnUse`〕、ほかのコマンドの返事待ちでない)、違和感があるときの[目のカメラの校正(18秒)]、細めも送るときの[ユーザー校正(最初に 1 回)](どちらも ③ と同じカードで進み、終わると結果のカード)、こんなときは。見開きの出やすさは「まぶた」タブ。`--fake-eyecam` と `--fake-password`、`--fake-camera` で描く | -| 詳細 | いちばん上の切り替え(バージョン / 困ったとき / 動作確認 / ファイル。英語は Version / Having trouble / Debug tools / Files)で 1 ページずつ出す。初めて開いたときは「バージョン」、そのあとは最後に開いた小タブ(パネルを起動し直しても。下の「最後に開いた小タブ」)。ほかのタブから戻っても同じ小タブ。新しい版があるあいだは「バージョン」の後ろに小さな点。「バージョン」: 今の版(大きく)と、最後に確かめた時刻・結果(「21:10 に確認・新しい版 0.7.6 があります」。確かめられなかった・更新に失敗したときは赤で、理由は 4 行、くわしくの場所は 2 行まで)。新しい版があれば右に[0.7.6 に更新する](確認を 1 回はさむ)、その下の箱に「0.7.6 の内容」としてその版の要約を 4 行まで(日本語の画面ではリリースの「日本語:」の段落、無ければ英語。要約の無いリリースなら箱を出さない)。更新に失敗したら[もう一度][閉じる]、入れ終わったら[閉じる]。いちばん下の行に[更新履歴](下を参照)、「起動時と 1 時間ごとに確認 オン / オフ」のチップ(押すと切り替わる)、右に[今すぐ確かめる](確かめている間と更新中は押せない)。カードのいちばん下に「ささけん@の Frame アプリ」の行(下の「ほかの Frame アプリ」)。「困ったとき」: 「診断」の行(「困ったら、診断の画面のスクショを送ってね」、診断コードのチップ、[開く]で下の「診断」ページ)、「最近の記録」(新しい 3 件。時刻・何をしたか・短い理由・OK / 失敗 / 片目だけ・[見る]。下の「記録」)、「SSH なら frameeyeosc-panel --report latest」と[すべての記録(N)›]。「動作確認」: 視線の点を表示(デバッグ用、オン / オフ)と点の距離(0.3〜2.0 m、0.1 m 刻み。1.2 m くらいより奥だと開いたダッシュボードに隠れる。点がオフの間は灰色)、そのすぐ下の行に「倍率の前の向き(輪)」(ヒント「目が見ている向き」。英語は Ring: before the scale / Where your eyes look。オン / オフは点のスイッチと同じ幅で真下に並び、右に「視線の倍率が 100 % でないときだけ、点のまわりに輪で出ます」〔英語は Shown around the dot only while a gaze scale isn't 100 %〕を 2 行まで。点がオフの間は選んでいる側を枠だけにして灰色、押せない)、目のログ(ボタンの右に保存先と 60 分の上限、目のカメラを使っているときは「目のカメラの数字(映像なし)も ~/eyecam/drift/ に残します」も。記録できなかったらその理由を 3 行まで。目のカメラを使っていて eyecam-rec が driftlog を持っていれば、記録の間 eyecam のドリフトの記録も取る: 始めに `driftlog on eyes_日時`、終わりに `driftlog off`。記録の途中で eyecam-rec が起き直してドリフトの記録が止まったら、もう一度 `driftlog on`〔断られたら 30 秒おき〕。recorder.h の DriftLink)、eyecam-rec が動いているあいだは「開発用」のバッジ付きの「目の撮影」([撮影開始]で下の光の注意。ボタンの右に「目の映像が ~/eyecam/rec_日時/ に残るよ(1 回で約 2.5 GB)」)、その下に「細めの学習用に撮る」(ヒント「半目の読み直し(AI)の学習用・任意」、[撮る]と[録ったもの]〔フォルダがあるときだけ〕。目のカメラの準備が済むまではどちらも押せず、右に「目のカメラの準備が済むと使えるよ」。準備のあとは右に、1 本目の前は「目の映像を約 1 分半録って、開発者に送る手伝い(1 本 約 2.7 GB)」、そのあとは「これまで 3 本(8.2 GB)」。下の「細めの学習用に撮る」)。「ファイル」: 設定ファイル・キャリブレーション・状態ファイル・記録の場所、本体の PID と動いている時間、コマンドで固定中の項目と今の値(3 行まで) | +| 詳細 | いちばん上の切り替え(バージョン / 困ったとき / 動作確認 / ファイル。英語は Version / Having trouble / Debug tools / Files)で 1 ページずつ出す。初めて開いたときは「バージョン」、そのあとは最後に開いた小タブ(パネルを起動し直しても。下の「最後に開いた小タブ」)。ほかのタブから戻っても同じ小タブ。新しい版があるあいだは「バージョン」の後ろに小さな点。「バージョン」: 今の版(大きく)と、最後に確かめた時刻・結果(「21:10 に確認・新しい版 0.7.6 があります」。確かめられなかった・更新に失敗したときは赤で、理由は 4 行、くわしくの場所は 2 行まで)。新しい版があれば右に[0.7.6 に更新する](確認を 1 回はさむ)、その下の箱に「0.7.6 の内容」としてその版の要約を 4 行まで(日本語の画面ではリリースの「日本語:」の段落、無ければ英語。要約の無いリリースなら箱を出さない)。更新に失敗したら[もう一度][閉じる]、入れ終わったら[閉じる]。いちばん下の行に[更新履歴](下を参照)、「起動時と 1 時間ごとに確認 オン / オフ」のチップ(押すと切り替わる)、右に[今すぐ確かめる](確かめている間と更新中は押せない)。カードのいちばん下に「ささけん@の Frame アプリ」の行(下の「ほかの Frame アプリ」)。「困ったとき」: 「診断」の行(「困ったら、診断の画面のスクショを送ってね」、診断コードのチップ、[開く]で下の「診断」ページ)、「最近の記録」(新しい 3 件。時刻・何をしたか・短い理由・OK / 失敗 / 片目だけ・[見る]。下の「記録」)、「SSH なら frameeyeosc-panel --report latest」と[すべての記録(N)›]。「動作確認」: 視線の点を表示(デバッグ用、オン / オフ)と点の距離(0.3〜2.0 m、0.1 m 刻み。1.2 m くらいより奥だと開いたダッシュボードに隠れる。点がオフの間は灰色)、そのすぐ下の行に「倍率の前の向き(輪)」(ヒント「目が見ている向き」。英語は Ring: before the scale / Where your eyes look。オン / オフは点のスイッチと同じ幅で真下に並び、右に「視線の倍率が 100 % でないときだけ、点のまわりに輪で出ます」〔英語は Shown around the dot only while a gaze scale isn't 100 %〕を 2 行まで。点がオフの間は選んでいる側を枠だけにして灰色、押せない)、目のログ(ボタンの右に保存先と 60 分の上限、目のカメラを使っているときは「目のカメラの数字(映像なし)も ~/eyecam/drift/ に残します」も。記録できなかったらその理由を 3 行まで。目のカメラを使っていて eyecam-rec が driftlog を持っていれば、記録の間 eyecam のドリフトの記録も取る: 始めに `driftlog on eyes_日時`、終わりに `driftlog off`。記録の途中で eyecam-rec が起き直してドリフトの記録が止まったら、もう一度 `driftlog on`〔断られたら 30 秒おき〕。recorder.h の DriftLink)、eyecam-rec が動いているあいだは「開発用」のバッジ付きの「目の撮影」([撮影開始]で下の光の注意。ボタンの右に「目の映像が ~/eyecam/rec_日時/ に残るよ(1 回で約 2.5 GB)」)、その下に「細めの学習用に撮る」(ヒント「半目の読み直し(AI)用・任意」、[撮る]と[録ったもの]〔フォルダがあるときだけ〕。目のカメラの準備が済むまではどちらも押せず、右に「目のカメラの準備が済むと使えるよ」。準備のあとは右に、1 本目の前は「目の映像を約 1 分半録って、開発者に送る手伝い(1 本 約 2.7 GB)」、そのあとは「これまで 3 本(8.2 GB)」。下の「細めの学習用に撮る」)。「ファイル」: 設定ファイル・キャリブレーション・状態ファイル・記録の場所、本体の PID と動いている時間、コマンドで固定中の項目と今の値(3 行まで) | - 目のプレビュー(`eye_preview.{h,cpp}`): 左の列の「目のプレビュー」で、タブの中身の上にシートを開く(ほかの Frame アプリの一覧と同じ枠と ✕。タブは並んだままで、どれも選ばれていない見た目になり、どのタブを押しても閉じる。左の列のお知らせを押しても、ダッシュボードを閉じても閉じる)。`--preview-sheet` で開いて描く - 上に「目のプレビュー」と ✕。その下の 1 行: 「送る値 / 生の値 / カメラの値」(どの値で目を描くか)、「鏡で見る」(既定オン: 鏡に映した向き。右目を右、左目を左に描き、右を見ると虹彩も右へ。オフでほかの人〔正面のアバター〕から見た向き: 右目を左に描き、左右の視線を反転)、「カメラ映像」(最初はオフ。うっかり目が映らないように。一度オンにすると、パネルが動いているあいだは開くたびにオンのまま。オフにするか、カメラが使えなくなると解除)、[止める]/[動かす](その時点の値と映像のまま止める)。スイッチは、オンで ✓ とアクセントの薄い塗り・太字。選んだものはパネルを起動しているあいだ覚えている(止める・動かすは開くたびに解除) @@ -265,13 +265,13 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ 「半目の読み直し(AI)」(eyecam のまぶたの見直しの CNN)が細めや半目を見分けられるように、学習用の目の映像を撮ってためておき、開発者に送ってもらうためのもの。ボタンを 1 回押せば最後まで進む: 初めて(と同意文の版が上がったとき)だけ同意と眼鏡・インサートの質問、そのあと eyecam の `protocol_contrib.txt`(約 93 秒。全体の光なし)を 1 回撮り、終わりの画面を出す。録画そのものは「目の撮影」と同じく eyecam-rec が `~/eyecam/rec_日時/` に書き(`cues.csv`・`valve.csv`・`frames.csv`・`meta.txt` もそのまま)、終わってその 4 つがそろっていれば、パネルが `~/eyecam/contrib/` に移して `info.txt` に 1 本ぶん書き足す。何回でも押せて、録画はそのフォルダにたまっていく。 - **入り口**: 「詳細」›「動作確認」の「目の撮影」の下の「細めの学習用に撮る」の[撮る]と[録ったもの](上の表)。目の撮影と同じく eyecam-rec が動いているあいだだけ出て、押せるのは目のカメラの準備が済んでから(`eyecam::setUp`)。「開発用」のバッジは付けない(使う人向け)。まぶたタブの「半目の読み直し(AI)」の行は使う人の設定なので、録画の入り口はそこに置かない -- **シート**: 詳細タブの中身の代わりに出る(タブの列と左の列はそのまま。どのタブ・小タブを押しても閉じる。ダッシュボードを閉じても開いたまま)。上に「細めの学習用に撮る」とヒント、右に × の[閉じる]。[撮る]で開くたびに、前の終わり・失敗の画面は忘れる。画面は `contrib::screen` が決める: +- **シート**: 詳細タブの中身の代わりに出る(タブの列と左の列はそのまま。どのタブ・小タブを押しても閉じる。ダッシュボードを閉じても開いたまま)。上に「細めの学習用に撮る」と「半目の読み直し(AI)を、いろんな目で賢くするための撮影だよ」、右に × の[閉じる]。[撮る]で開くたびに、前の終わり・失敗の画面は忘れる。画面は `contrib::screen` が決める: - **同意**(`info.txt` に同意が無い・同意文の版〔`contrib::kConsentVersion`、今は 1〕が古い・眼鏡の答えが無いとき): 「「半目の読み直し(AI)」が細めや半目をもっと正しく見分けられるように、学習用の目の映像を録るよ。協力するかどうかは自由です。」、見出しつきで「録るもの」(目のカメラの映像を 1 回約 1 分半〔1 本 約 2.7 GB〕。送るまでは Frame の中だけ)・「使いみち」(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く)・「消してほしいとき」(言えば消す。もう配った AI に入った分は取り出せない)、警告色の枠で送り方の注意「目の映像は GitHub の issue や公開の場所に上げないでね。開発者に非公開で送ってね(送り方は開発者が伝えます)。」(連絡先はアプリに入れない)、灰色で「画面が白や黒に光ることはないよ(光なしで撮るよ)。」(光の注意は要らない)。[やめる]と[同意して次へ]。消したあとは上に緑で「録画を消したよ」 - **眼鏡・インサート**: 「HMD の中で、眼鏡や度付きインサートを使ってる?」「学習のときに分けて見るためだけに使うよ。録画ごとに info.txt に書くよ」、同じ重さの 4 つ[使ってない][眼鏡][度付きインサート][答えない]と[戻る]。答えたら、そのとき同意と答えを `info.txt` に書く(フォルダが無ければ作る) - **撮る前**: 「細めの学習用の撮影(約 1 分半)」と点つきで「最初の 10 秒は、普通に開けて正面を見て待ってね」「ビープと画面の指示に合わせて、閉じる・普通・細める・普通・見開くを 3 回、そのあと上・下・左・右を見るよ」「細めるは、まぶしいときみたいに目を細める(閉じない)」「左・右は自分から見た向き。顔は正面のまま、目だけ動かしてね」「画面が白や黒に光ることはないよ」、録ったものがあれば灰色で「これまで 3 本・合計 8.2 GB」。ボタンの上に灰色で「空き容量 23.2 GB(1 本 約 2.7 GB)」(足りなければ赤で「空きが足りないよ(約 3.8 GB 要る)」、eyecam-rec がほかのことをしていれば「目のカメラがほかのことをしているあいだは待ってね」)、[撮影をはじめる(約 1 分半)]。押せるのは同意が済んで、eyecam-rec が待機中(かエラー)・コマンドの返事待ちでない・まとめていない・1 本ぶん(目のカメラの毎秒の枚数から。分からなければ 90)と 1 GiB が空いているとき(`contrib::startAllowed`) - **録画中**: eyecam-rec が探している・録っているあいだは目の撮影の画面(`drawRun`)のまま、見出しだけ「細めの学習用に撮る」(その前後のつなぎ目は見出しと「送っています…」だけ)。ステップのチップは `protocol_contrib.txt` から(23 ステップ)。左右を見る段は「左の耳の方を見て」「右の耳の方を見て」(チップは「左を見る」「右を見る」)。シートを閉じていても、詳細タブの録画の画面は同じ見出しになる - **録れなかった**: 赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった・ファイルが足りなかった〔そのファイル名〕)、途中までのフォルダがあれば灰色で「途中までの分は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度撮る] - - **終わり**(撮れたあと、または[録ったもの]): 撮れたあとは緑のチェックと「撮れたよ。ありがとう!」、[録ったもの]なら「録ったもの」。「これまで 4 本・合計 10.9 GB」、灰色で「場所: ~/eyecam/contrib/」、撮れたあとは「別の日や、かぶり直したあとにもう一度押してくれると、もっと助かるよ」、送り方の注意の枠。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.7 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(4 本・6.3 GB)」とその下にファイル名(それより本数が増えるまでは押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。まとめるときにファイルが足りなかった録画は赤で「rec_… はファイルが足りないので入れなかったよ。もう一度撮ってね」。下の左に[録画を消す]、右に[閉じる] + - **終わり**(撮れたあと、または[録ったもの]): 撮れたあとは緑のチェックと「撮れたよ。ありがとう!」、[録ったもの]なら「録ったもの」。「これまで 4 本・合計 10.9 GB」、灰色で「場所: ~/eyecam/contrib/」、撮れたあとは「別の日や、かぶり直したあとにもう一度押してくれると、もっと助かるよ」、送り方の注意の枠。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.7 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(4 本・6.3 GB)」とその下にファイル名(それより本数が増えるまでは押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。その下に灰色で「まとめたファイルは .tar.zst(7-Zip などで開けるよ)」(zstd が無くて .tar になるときは出さない)。まとめるときにファイルが足りなかった録画は赤で「rec_… はファイルが足りないので入れなかったよ。もう一度撮ってね」。下の左に[録画を消す]、右に[閉じる] - **消す確認**: 赤い枠のカードで「撮った録画を全部消す?」「~/eyecam/contrib/ を中身ごと(10.9 GB)消します。元に戻せないよ。開発者に送ったあとで消してね。」、[やめる](アクセント)と[消す]。消している間は「消しています…」、消したら同意の画面(同意も `info.txt` と一緒に消える) - **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib/info.txt` を読む。[撮影をはじめる]は `ctl.sock` に `start contrib` を送り、そのときの値を覚える(下の `info.txt`)。終わりは `status.json` だけで見る(`contrib::RunWatch`): `contrib` の `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだもので `frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろっていれば `rename` でフォルダへ移し、`~/eyecam/calib_日時/calib_result.json` のいちばん新しいもの(撮り始めたときに読んだもの)を `rec_日時.calib_result.json` として隣に置く。足りない・止まった・失敗したものは `aborted/` へ(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない)。パネルが録画の途中で起動し直したときは追わない(その録画は `~/eyecam/rec_日時/` に残る) - **info.txt**(`~/eyecam/contrib/info.txt`): 初めに何のファイルかのコメントと `format=2`、そのあと出来事ごとに節を書き足す。`[consent]`(`time`・`version`・`eyewear`: `none` / `glasses` / `inserts` / `no_answer`。最後の節が効く)、`[recording]`(`rec`・`protocol`・`start`・`start_unix`・`end_unix`・`bytes`・`app_version`・`eyecam_rec_sha256`〔入っている eyecam-rec の SHA-256 の先頭 8 桁。git のハッシュは持っていない〕・`lid_veto`(`on` / `off` / `unknown`)・`widen`・`widen_sensitivity`・`camera_lids`・`eyewear`・`camera_fps`・`minutes_since_previous`(前の録画が終わってから何分。1 本目は `none`)・`headset_off`(そのあいだに HMD を外したか: `yes` / `no` / `unknown` / 1 本目は `none`)・`headset_off_count_start` / `_end`・`eyecam_started_unix`・`calib_result`)、`[aborted]`(`rec`・`why`)、`[packed]`(`file`・`recordings`・`bytes`)。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index 4735deb..6da8f48 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -881,7 +881,8 @@ UiText makeJapanese() { t.devRecord = "目の撮影"; t.devRecordHint = "目のカメラの映像を録る"; t.contribRow = "細めの学習用に撮る"; - t.contribRowHint = "半目の読み直し(AI)の学習用・任意"; + t.contribRowHint = "半目の読み直し(AI)用・任意"; + t.contribAbout = "半目の読み直し(AI)を、いろんな目で賢くするための撮影だよ"; t.contribRecord = "撮る"; t.contribRecordings = "録ったもの"; t.contribRowIntroFormat = "目の映像を約 1 分半録って、開発者に送る手伝い(1 本 約 %s)"; @@ -940,6 +941,7 @@ UiText makeJapanese() { t.contribNext = "1 つのファイルにまとめて、開発者に送ってね。"; t.contribPack = "1 つのファイルにまとめる"; t.contribPackHintFormat = "元の録画はそのまま残るよ(空きが約 %s 要る)"; + t.contribPackFormatNote = "まとめたファイルは .tar.zst(7-Zip などで開けるよ)"; t.contribPackingFormat = "まとめています… %d%%"; t.contribPackedFormat = "できたよ(%s 本・%s)"; t.contribPackFailedFormat = "まとめられなかった: %s"; @@ -1930,6 +1932,7 @@ UiText makeEnglish() { t.devRecordHint = "Records the eye cameras"; t.contribRow = "Record for squint training"; t.contribRowHint = "For the half-shut check (optional)"; + t.contribAbout = "Recordings that make the half-shut check (AI) smarter on all kinds of eyes"; t.contribRecord = "Record"; t.contribRecordings = "Recordings"; t.contribRowIntroFormat = "Record your eyes for about 1.5 minutes and send it to the developer (about %s each)"; @@ -1990,6 +1993,7 @@ UiText makeEnglish() { t.contribNext = "Make it one file and send it to the developer."; t.contribPack = "Make one file"; t.contribPackHintFormat = "The recordings stay as they are (needs about %s free)"; + t.contribPackFormatNote = "The file is a .tar.zst (7-Zip and the like can open it)"; t.contribPackingFormat = "Packing… %d%%"; t.contribPackedFormat = "Done (%s recordings, %s)"; t.contribPackFailedFormat = "Couldn't pack it: %s"; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index 047a856..b8585f1 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -889,6 +889,7 @@ struct UiText { // "Record for squint training" (contrib.h): its row on Debug tools, and its sheet const char* contribRow; ///< the row's title, and the sheet's const char* contribRowHint; ///< ...under the title + const char* contribAbout; ///< the sheet's line under its title: what it is for const char* contribRecord; ///< ...its "Record" button const char* contribRecordings; ///< ...its "Recordings" button (the folder: pack, delete) const char* contribRowIntroFormat; ///< ...beside them before the first (%s: one recording's size) @@ -936,6 +937,7 @@ struct UiText { const char* contribNext; ///< ...what to do with it const char* contribPack; ///< "Make one file" const char* contribPackHintFormat; ///< ...beside it (%s: the size it needs free) + const char* contribPackFormatNote; ///< ...under it: what the file is, and what opens it const char* contribPackingFormat; ///< ...while packing (%d %) const char* contribPackedFormat; ///< ...done (%s recordings, %s its size; its name under it) const char* contribPackFailedFormat; ///< ...failed (%s why) diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index 2d1b29d..a94cee5 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -7053,7 +7053,7 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) const double titleRight = closeX - 16; pen.text(kInnerX, headY + 26, t.contribRow, fitSize(pen, t.contribRow, 22, 16, titleRight - kInnerX, true), kText, true); - pen.text(kInnerX, headY + 50, ellipsize(pen, t.contribRowHint, 14, false, titleRight - kInnerX, false), 14, + pen.text(kInnerX, headY + 50, ellipsize(pen, t.contribAbout, 14, false, titleRight - kInnerX, false), 14, kTextMuted); if (contribConfirm_) { drawContribDelete(pen, t, m); @@ -7292,6 +7292,8 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) baseline += 20; } y += packH + 8; + // (what the file is: zstd's, which Windows doesn't open by itself) + if (c.compress) paragraph(t.contribPackFormatNote, 14, kTextMuted, false, 1); // Recordings the pack left out (a file missing): to record again if (!c.incomplete.empty()) { std::string names; From 07ce873c9020ff54d334243900590510d3a2ecbb Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 12:55:39 +0900 Subject: [PATCH 39/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E3=80=8C?= =?UTF-8?q?=E7=B4=B0=E3=82=81=E3=81=AE=E5=AD=A6=E7=BF=92=E7=94=A8=E3=81=AB?= =?UTF-8?q?=E6=92=AE=E3=82=8B=E3=80=8D=E3=81=AE=E9=8C=B2=E7=94=BB=E3=81=8C?= =?UTF-8?q?=E5=AD=A6=E7=BF=92=E3=81=AB=E4=BD=BF=E3=81=88=E3=82=8B=E3=81=8B?= =?UTF-8?q?=E3=82=92=E7=A2=BA=E3=81=8B=E3=82=81=E3=80=81=E6=A0=A1=E6=AD=A3?= =?UTF-8?q?=E3=81=A8=E8=A8=AD=E5=AE=9A=E3=81=AE=E5=86=99=E3=81=97=E3=82=82?= =?UTF-8?q?=E7=BD=AE=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 つのファイル(eye_L/R.raw・frames/valve/cues.csv・meta.txt)がそろっているか、目ごとの raw の大きさが frames.csv の行数 × frame_bytes と ちょうど合うか(途中で切れた raw をはじく)、フレームの 9 割以上に 0.05 秒以内の使える Valve のサンプル(producer_state 1)があるかを、 CSV の行とファイルの大きさだけで見る(contrib::checkRecording)。使えないものは aborted/ に入れ、わけと「もう一度撮ってね」を出す。 まとめるときは別のスレッドで同じ条件で確かめ直す。録画ごとにユーザー校正(calib.json)と settings.json の写しを隣に置き、info.txt に user_calib / settings を書く。同意文の「録るもの」に校正の数字と設定を足す(版は 1 のまま)。テストとドキュメントも合わせる。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 2 +- CHANGELOG.md | 2 +- README.ja.md | 2 +- README.md | 2 +- panel/README.md | 18 +-- panel/src/contrib.cpp | 211 ++++++++++++++++++++++++++++------- panel/src/contrib.h | 75 ++++++++++--- panel/src/i18n.cpp | 25 +++-- panel/src/i18n.h | 4 +- panel/src/main.cpp | 17 ++- panel/src/panel.cpp | 2 + panel/tests/contrib_test.cpp | 211 +++++++++++++++++++++++++++++------ 12 files changed, 454 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 189441d..d15b271 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -5,7 +5,7 @@ パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。 - 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります -- 録画は `~/eyecam/contrib/` にたまります。`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろったものだけを移し(足りない・途中で止まったものは `aborted/` に入れて撮り直せる)、いちばん新しい校正結果の写しを隣に置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません +- 録画は `~/eyecam/contrib/` にたまります。学習に使えるものだけを移します: `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろい、目ごとの raw がちょうど `frames.csv` のその目の枚数ぶんあり(ディスクがいっぱいで途中で切れたものをはじく)、フレームの 9 割以上に Valve の目のトラッキングの値がある。CSV とファイルの大きさだけで確かめ、まとめるときにも確かめ直します。使えない・途中で止まったものはわけと一緒に `aborted/` に入れて、撮り直してもらいます。隣には、いちばん新しい校正結果・ユーザー校正(`calib.json`)・eyecam の設定の写しを置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません - 終わりの画面([録ったもの]からも)に、本数と合計の大きさ、フォルダ、送り方、[1 つのファイルにまとめる](`zstd -6` で `.tar.zst`。Frame の目の映像で 0.58 倍だった。7-Zip などで開ける。zstd が無ければ `.tar`。空き容量を確かめてから、低い優先度で裏でまとめる。元の録画は残す)、[録画を消す](確認つき)を出します - eyecam: 新しい `protocol_contrib.txt`。`look_left`(長 2 回、高→低)と `look_right`(長 2 回、低→高)に専用のビープ(今までは細めと同じ音だった)。status.json に `headset_off_count`(eyecam-rec が起動してから、目の映像が止まるか近接センサーで 10 秒以上外していて、またかぶった回数。サスペンドも数える時計で測る)と `started_unix` を足しました - `frameeyeosc-panel --dump-png … --fake-contrib 状態` で、それぞれの画面を描けます diff --git a/CHANGELOG.md b/CHANGELOG.md index dcbac7b..235eef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ New in the panel: "Record for squint training" (Advanced › Debug tools, once t 日本語: パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。 - "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (privately, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). -- The recordings gather in `~/eyecam/contrib/`, each moved there once it has `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` (one without them, or stopped early, goes to `aborted/` and can be recorded again), with a copy of the newest calibration result beside it. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. +- The recordings gather in `~/eyecam/contrib/`, each moved there once it can be used for training: `eye_L.raw`, `eye_R.raw`, `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` there, each raw file exactly as many frames as `frames.csv` lists for that eye (one cut short when the disk ran full is caught), and Valve's tracking values near at least 90 % of the frames; checked from the CSV files and file sizes alone, again when packing. One that can't be used, or stopped early, goes to `aborted/` with why, to be recorded again. Beside each go copies of the newest calibration result, the user calibration (`calib.json`) and eyecam's settings then. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. - The end's screen (also from "Recordings") shows how many there are and their size, the folder, how to send it, "Make one file" (a `.tar.zst` through `zstd -6`, measured on the Frame's eye video at 0.58 of the size, which 7-Zip and the like open; a `.tar` without zstd; packed in the background at a low priority after checking the free space; the recordings stay) and "Delete the recordings" (after a confirmation). - eyecam: new `protocol_contrib.txt`; new beeps for `look_left` (2 long, high then low) and `look_right` (2 long, low then high), which had the squint's beep; status.json's new `headset_off_count` (the times the headset was taken off for 10 s or more and worn again since eyecam-rec started, by the eye video stopping or the proximity sensor, on a clock that counts suspend) and `started_unix`. - `frameeyeosc-panel --dump-png … --fake-contrib STATE` draws each of its screens. diff --git a/README.ja.md b/README.ja.md index aa36445..a59e058 100644 --- a/README.ja.md +++ b/README.ja.md @@ -499,7 +499,7 @@ eyecam-rec は、受け取ったバッファも Valve の目のデータも読 - パネルが `$XDG_RUNTIME_DIR/frameeyeosc/` に書くもの(メモリの上にあり、再起動で消える): 目を合わせるときの音のファイル `sounds/`(起動時に書く) - パネルが `~/.local/state/frameeyeosc/` に書くもの: 校正と目合わせの記録 `reports/`(新しい 10 件。[パネル](#パネル)を参照)と、`panel.json`(詳細タブ・まぶたタブ・視線タブで最後に開いたページ、視線の倍率の鍵、直近 5 回の全部合わせの上の幅)。記録を作るときは、その前後のジャーナル(`journalctl --user`、読むだけ)と Valve の `~/.local/share/Steam/logs/eyetracking.txt` を読みます - - eyecam(目のカメラ)が書くもの: 設定と校正 `~/.config/eyecam/`(`settings.json` に見開きの感度、`calib.json` に校正で測った値)、メモリの上(再起動で消える)の決まった場所: `/run/user/1000/eyecam/` に状態ファイル・操作用のソケット・frameeyeosc が読む目の値(と、パネルの目のプレビューでカメラ映像を出している間だけ、その映像。下を参照)、そのフォルダーの隣に eyecam-grab がバッファを渡すソケット `/run/user/1000/eyecam.sock`。systemd のジャーナル(`journalctl --user -u eyecam`)に、30 秒ごとに 1 行(処理したフレームの数、1 フレームの時間、左右の瞳孔のふだんの差)と、校正のたびに測った値の報告。校正では目の映像を残しません(開発者モードを除く)。校正のたびに(準備のときもあとからも、失敗したり止めたりしても)、`~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/` に小さなテキストのファイルだけ(1 MB ほど: 結果、フレームごとに出した値、フレームの時刻、Valve の値)を残します。eyecam の開発者モード(`~/.config/eyecam/settings.json` の `"dev": true`。手で書き換えるもので、最初はオフ)のときだけ、その 18 秒の目のカメラの映像も残します(1 回で約 0.5 GB)。詳細タブの開発用の「目の撮影」は、いつも目の映像を `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` に残します(1 回で約 2.5 GB。始める前にパネルにも出ます)。詳細タブの「細めの学習用に撮る」は、最初の画面で同意したときだけ、録画(目の映像。1 本 約 2.7 GB)を `~/eyecam/contrib/` に、`info.txt`(アプリの版・時刻・そのときの設定・眼鏡やインサートの答え・録画のあいだに HMD を外したか・フォルダ名・大きさ。個人の情報は入れない)と、録画ごとの校正結果の写し、まとめた 1 つのファイルと一緒に残します。パネルはどれもどこにも送りません(渡すかはあなたが決めます。公開の場所には上げないでください)。[録画を消す]でフォルダごと消せます。目のカメラを使っていてパネルの目のログを記録している間([開発](#開発)を参照)と、自分で eyecam に `driftlog on` を送ったとき(調べもの用)だけ、記録が終わるか `driftlog off` まで、数字だけ(映像なし)を `~/eyecam/drift/` に書きます(目のログと一緒なら `eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv`、手で送ったら `drift_YYYY-MM-DD_HH-MM-SS.csv`): 目ごとに 1 秒 1 行(瞳を見つけた位置、比べている基準の値、出した値、Valve の開き具合と視線の上下。1 時間で約 1.3 MB、1 ファイル 50 MB まで)。どれもヘッドセットの外には出しません。要らなくなったら消してください + - eyecam(目のカメラ)が書くもの: 設定と校正 `~/.config/eyecam/`(`settings.json` に見開きの感度、`calib.json` に校正で測った値)、メモリの上(再起動で消える)の決まった場所: `/run/user/1000/eyecam/` に状態ファイル・操作用のソケット・frameeyeosc が読む目の値(と、パネルの目のプレビューでカメラ映像を出している間だけ、その映像。下を参照)、そのフォルダーの隣に eyecam-grab がバッファを渡すソケット `/run/user/1000/eyecam.sock`。systemd のジャーナル(`journalctl --user -u eyecam`)に、30 秒ごとに 1 行(処理したフレームの数、1 フレームの時間、左右の瞳孔のふだんの差)と、校正のたびに測った値の報告。校正では目の映像を残しません(開発者モードを除く)。校正のたびに(準備のときもあとからも、失敗したり止めたりしても)、`~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/` に小さなテキストのファイルだけ(1 MB ほど: 結果、フレームごとに出した値、フレームの時刻、Valve の値)を残します。eyecam の開発者モード(`~/.config/eyecam/settings.json` の `"dev": true`。手で書き換えるもので、最初はオフ)のときだけ、その 18 秒の目のカメラの映像も残します(1 回で約 0.5 GB)。詳細タブの開発用の「目の撮影」は、いつも目の映像を `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` に残します(1 回で約 2.5 GB。始める前にパネルにも出ます)。詳細タブの「細めの学習用に撮る」は、最初の画面で同意したときだけ、録画(目の映像。1 本 約 2.7 GB)を `~/eyecam/contrib/` に、そのときの校正と eyecam の設定の写し、`info.txt`(アプリの版・時刻・そのときの設定・眼鏡やインサートの答え・録画のあいだに HMD を外したか・フォルダ名・大きさ。個人の情報は入れない)と、録画ごとの校正結果の写し、まとめた 1 つのファイルと一緒に残します。パネルはどれもどこにも送りません(渡すかはあなたが決めます。公開の場所には上げないでください)。[録画を消す]でフォルダごと消せます。目のカメラを使っていてパネルの目のログを記録している間([開発](#開発)を参照)と、自分で eyecam に `driftlog on` を送ったとき(調べもの用)だけ、記録が終わるか `driftlog off` まで、数字だけ(映像なし)を `~/eyecam/drift/` に書きます(目のログと一緒なら `eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv`、手で送ったら `drift_YYYY-MM-DD_HH-MM-SS.csv`): 目ごとに 1 秒 1 行(瞳を見つけた位置、比べている基準の値、出した値、Valve の開き具合と視線の上下。1 時間で約 1.3 MB、1 ファイル 50 MB まで)。どれもヘッドセットの外には出しません。要らなくなったら消してください 上の eyecam の校正のファイル・録画・ドリフトの記録・ジャーナルの行と、パネルの記録(その `status.jsonl` には、校正や目合わせの間 1 秒ごとの本体と eyecam の状態が目の値ごと入ります)のほかは、目のデータは保存しません。ただし目を合わせたときに測った値(視線の平均の向きとばらつき、目ごとの開き具合の平均)は、1 回ごとに 1 行 systemd のジャーナルに残り、まぶたの読んだ値は `config.json` に残ります。最新の目の値は状態ファイルにありますが、これはメモリの上にあって本人しか読めず、1 秒に 10 回上書きされます。履歴は残しません - OSC は暗号化されないので、同じネットワーク上の他の機器から読める可能性があります diff --git a/README.md b/README.md index 192361f..6444b41 100644 --- a/README.md +++ b/README.md @@ -499,7 +499,7 @@ To update again later, use the panel's update button or the usual install comman - from the panel, in `$XDG_RUNTIME_DIR/frameeyeosc/` (in memory, gone after a reboot): the eye fit's sound files in `sounds/`, written at start - from the panel, in `~/.local/state/frameeyeosc/`: the records of calibrations and eye fits in `reports/` (the newest 10; see [Panel](#panel)) and in `panel.json` the last page of the Advanced, Eyelids and Gaze tabs, the gaze scale's locks and the up ranges of the last 5 full eye fits. To make a record the panel reads the journal (`journalctl --user`, read only) and Valve's `~/.local/share/Steam/logs/eyetracking.txt` for the minute around it - - from eyecam (the eye cameras): its settings and calibration in `~/.config/eyecam/` (`settings.json` with the widening sensitivity, `calib.json` with what the calibrations measured), and in memory (gone after a reboot) at fixed paths: `/run/user/1000/eyecam/` with its status file, its control socket and the eye values frameeyeosc reads (and, only while the panel's eye preview shows the camera picture, the preview's pictures: see below), and next to that folder `/run/user/1000/eyecam.sock`, the socket eyecam-grab hands the buffers to. In the systemd journal (`journalctl --user -u eyecam`), every 30 seconds one line with how many frames it worked out, how long each took and the usual difference between the left and right pupil, and after each calibration a report of what it measured. Calibrations keep no eye video: each one (the setup's and later ones, also one that failed or was stopped) leaves only small text files in `~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/`, about 1 MB: the result, the values worked out for each frame, the frame times and Valve's readings. Only in eyecam's developer mode (`"dev": true` in `~/.config/eyecam/settings.json`, set by hand; off by default) do they keep the 18 seconds of eye-camera video too (about 0.5 GB each). The developer eye recording on the Advanced tab always keeps eye video, in `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` (about 2.5 GB each time); the panel says so before it starts. "Record for squint training" on the Advanced tab, only after you agree on its first screen, keeps its recordings (eye video, about 2.7 GB each) in `~/eyecam/contrib/` with an `info.txt` (the app version, times, the settings then, your answer about glasses or inserts, whether the headset was taken off between recordings, folder names and sizes; nothing personal), a copy of the calibration result next to each, and the one file it packs them into; the panel sends none of it anywhere (sharing it is up to you, never in a public place), and its "Delete the recordings" removes the folder. While the panel's eye log records with the eye cameras in use (see [Development](#development)), and if you send eyecam `driftlog on` yourself (an investigation tool), it writes numbers only, no pictures, to `~/eyecam/drift/` (`eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv` beside the eye log, `drift_YYYY-MM-DD_HH-MM-SS.csv` by hand): one line per eye per second (where it found the pupil, the levels it compares with, the values it worked out and Valve's openness and pitch; about 1.3 MB an hour, at most 50 MB a file) until the recording ends or `driftlog off`. Nothing of it leaves the headset; delete them when you no longer need them + - from eyecam (the eye cameras): its settings and calibration in `~/.config/eyecam/` (`settings.json` with the widening sensitivity, `calib.json` with what the calibrations measured), and in memory (gone after a reboot) at fixed paths: `/run/user/1000/eyecam/` with its status file, its control socket and the eye values frameeyeosc reads (and, only while the panel's eye preview shows the camera picture, the preview's pictures: see below), and next to that folder `/run/user/1000/eyecam.sock`, the socket eyecam-grab hands the buffers to. In the systemd journal (`journalctl --user -u eyecam`), every 30 seconds one line with how many frames it worked out, how long each took and the usual difference between the left and right pupil, and after each calibration a report of what it measured. Calibrations keep no eye video: each one (the setup's and later ones, also one that failed or was stopped) leaves only small text files in `~/eyecam/calib_YYYY-MM-DD_HH-MM-SS/`, about 1 MB: the result, the values worked out for each frame, the frame times and Valve's readings. Only in eyecam's developer mode (`"dev": true` in `~/.config/eyecam/settings.json`, set by hand; off by default) do they keep the 18 seconds of eye-camera video too (about 0.5 GB each). The developer eye recording on the Advanced tab always keeps eye video, in `~/eyecam/rec_YYYY-MM-DD_HH-MM-SS/` (about 2.5 GB each time); the panel says so before it starts. "Record for squint training" on the Advanced tab, only after you agree on its first screen, keeps its recordings (eye video, about 2.7 GB each) in `~/eyecam/contrib/` with copies of the calibrations and eyecam's settings then, an `info.txt` (the app version, times, the settings then, your answer about glasses or inserts, whether the headset was taken off between recordings, folder names and sizes; nothing personal), a copy of the calibration result next to each, and the one file it packs them into; the panel sends none of it anywhere (sharing it is up to you, never in a public place), and its "Delete the recordings" removes the folder. While the panel's eye log records with the eye cameras in use (see [Development](#development)), and if you send eyecam `driftlog on` yourself (an investigation tool), it writes numbers only, no pictures, to `~/eyecam/drift/` (`eyes_YYYY-MM-DD_HH-MM-SS.eyecam.csv` beside the eye log, `drift_YYYY-MM-DD_HH-MM-SS.csv` by hand): one line per eye per second (where it found the pupil, the levels it compares with, the values it worked out and Valve's openness and pitch; about 1.3 MB an hour, at most 50 MB a file) until the recording ends or `driftlog off`. Nothing of it leaves the headset; delete them when you no longer need them Apart from eyecam's calibration files, recordings, drift logs and journal lines above, and the panel's records (their `status.jsonl` keeps frameeyeosc's and eyecam's status, with the eye values, once a second while a calibration or fit ran), no eye data is stored, except that each eye fit measurement (an average gaze direction, how much it spread, and each eye's average openness) is logged as one line to the systemd journal, and the eyelid readings of a fit are kept in `config.json`. The latest eye values are in the status file, which is in memory, readable only by you, and overwritten ten times a second; no history is kept. - The OSC messages are unencrypted, so other devices on the same network could read them. diff --git a/panel/README.md b/panel/README.md index 675f639..fea5a00 100644 --- a/panel/README.md +++ b/panel/README.md @@ -243,7 +243,7 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - `--report-make 種類 始め 終わり 結果 理由`(確かめるためのもの): その時間のジャーナルと Valve のログから、パネルが走り終わったあとに書くのと同じ記録を `--reports-dir` に書き、フォルダを出す(例: `--report-make fit "2026-10-06 20:45:09" "2026-10-06 20:45:17" failed "…"`。条件と診断コードは空) - 更新履歴は `--history` で開き、`--history-open 版` でその版の行を開き、`--history-scroll px` でスクロールして描く(どちらも `--history` を含む。スクロールは中身の範囲に収める)。変更履歴は上の場所から探す。`--changelog-dir DIR` でそのフォルダだけを見る(無いフォルダなら「見つかりません」の見た目)。`--fake` とも一緒に使える - `--click X,Y`(何回でも)は、描く前にその座標を押したことにする。当たり判定と設定ファイルの書き込みをヘッドセットなしで確かめる用(`--fake` とは一緒に使えない。`--config` の設定ファイルを本当に書き換えるので、試すときは別の場所を指定する) -- 「細めの学習用に撮る」(下)は `--fake-contrib 状態` で、作り物のフォルダ(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意)・`deleted`(消したあとの同意)・`eyewear`(眼鏡・インサートの質問)・`ready`(撮る前。これまで 3 本)・`nospace`(空きが足りない)・`running`(録画中: 2 回目の細め)・`failed`(途中で止まった)・`incomplete`(ファイルが足りなかった)・`done`(撮れた: 4 本)・`list`([録ったもの]。1 本はファイルが足りずにまとめから外れた)・`packing`(まとめている 45 %)・`packed`(まとめた)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」の行を描く(`none` で 1 本目の前)。何も読み書きしない +- 「細めの学習用に撮る」(下)は `--fake-contrib 状態` で、作り物のフォルダ(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意)・`deleted`(消したあとの同意)・`eyewear`(眼鏡・インサートの質問)・`ready`(撮る前。これまで 3 本)・`nospace`(空きが足りない)・`running`(録画中: 2 回目の細め)・`failed`(途中で止まった)・`incomplete`(ファイルが足りなかった)・`truncated`(raw が途中で切れていた)・`valve`(Valve の値が足りなかった)・`done`(撮れた: 4 本)・`list`([録ったもの]。1 本はファイルが足りずにまとめから外れた)・`packing`(まとめている 45 %)・`packed`(まとめた)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」の行を描く(`none` で 1 本目の前)。何も読み書きしない - 目の撮影タブ(下)は `--fake-eyecam waiting|idle|confirm|searching|recording:ラベル|error` と `--tab eyecam` で描く(`confirm` は idle で光の注意を開いたところ。`recording:ラベル:unlocked` でカメラが目を見失ったとき=「HMD をかぶってください」、`recording:ラベル:nolight` で光なしのプロトコル=「光なし」。`:pupil=0.97,0.04` で目ごとの瞳の見え具合(`null` も可。無ければ古い eyecam-rec)、`:failed=L|R|LR|mixed`(`:prov`)で片目だけ失敗した校正や両目の失敗のメッセージ。`:page=user:lookdown=L|R|LR` で、下を見たときの補正をその目(LR は両目)だけ標準の値にして通ったユーザー校正のカード、`:lookprev=L|R|LR` で前の値のままにして通ったカード(`lookdown=R:lookprev=L` で目ごとに違うとき)。`:search=not_worn|no_video|one_eye` で映像が見つからない理由(`status.json` の `search`。`not_worn` は近接センサー 12)。ラベルは lead_in・normal・widen・close・squint・look_up・look_down・bright・dark・end。recording は eyecam の `protocol_widen.txt`(17 ステップ・83 秒。`nolight` なら `protocol_widen_nolight.txt` の 14 ステップ)でそのラベルが最初に出るステップになり、その手順をステップのチップにも使う(ファイルは読まず、同じ手順を main.cpp に持っている)。error は失敗した返事も出す)。`--eyecam-fill-png PATH`(`--eyecam-fill bright|dark`、`--language`)で全面の光の画像。`--eyecam-dir DIR` で eyecam-rec のフォルダ(`status.json` と `ctl.sock`)を別の場所にする。`--fake` なしの `--dump-png` はそこを読み、`--click` で[撮影開始]→[光ありで始める]/[光なしで始める]や[中止]を押すとそのソケットに本当に送って返事を待つ(試すときは一時フォルダに偽の `status.json` とソケットを置く。本物の `/run/user/1000/eyecam/` には何も作らない) - `--probe` は Background 型でつなぐだけで、オーバーレイも Vulkan も作らない。`FindOverlay`・名前・幅・閉じるボタン・表示中か・`GetOverlayTextureSize` を出す - `contrib/icons/frameeyeosc-panel-{48,128,256}.png` は `--thumbnail-png` で書き出したもの(ダッシュボードのサムネイルと同じ絵) @@ -262,21 +262,21 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ ### 細めの学習用に撮る(contrib) -「半目の読み直し(AI)」(eyecam のまぶたの見直しの CNN)が細めや半目を見分けられるように、学習用の目の映像を撮ってためておき、開発者に送ってもらうためのもの。ボタンを 1 回押せば最後まで進む: 初めて(と同意文の版が上がったとき)だけ同意と眼鏡・インサートの質問、そのあと eyecam の `protocol_contrib.txt`(約 93 秒。全体の光なし)を 1 回撮り、終わりの画面を出す。録画そのものは「目の撮影」と同じく eyecam-rec が `~/eyecam/rec_日時/` に書き(`cues.csv`・`valve.csv`・`frames.csv`・`meta.txt` もそのまま)、終わってその 4 つがそろっていれば、パネルが `~/eyecam/contrib/` に移して `info.txt` に 1 本ぶん書き足す。何回でも押せて、録画はそのフォルダにたまっていく。 +「半目の読み直し(AI)」(eyecam のまぶたの見直しの CNN)が細めや半目を見分けられるように、学習用の目の映像を撮ってためておき、開発者に送ってもらうためのもの。ボタンを 1 回押せば最後まで進む: 初めて(と同意文の版が上がったとき)だけ同意と眼鏡・インサートの質問、そのあと eyecam の `protocol_contrib.txt`(約 93 秒。全体の光なし)を 1 回撮り、終わりの画面を出す。録画そのものは「目の撮影」と同じく eyecam-rec が `~/eyecam/rec_日時/` に書き(`cues.csv`・`valve.csv`・`frames.csv`・`meta.txt` もそのまま)、終わって学習に使える形なら(下の「進め方」)、パネルが `~/eyecam/contrib/` に移して `info.txt` に 1 本ぶん書き足す。何回でも押せて、録画はそのフォルダにたまっていく。 - **入り口**: 「詳細」›「動作確認」の「目の撮影」の下の「細めの学習用に撮る」の[撮る]と[録ったもの](上の表)。目の撮影と同じく eyecam-rec が動いているあいだだけ出て、押せるのは目のカメラの準備が済んでから(`eyecam::setUp`)。「開発用」のバッジは付けない(使う人向け)。まぶたタブの「半目の読み直し(AI)」の行は使う人の設定なので、録画の入り口はそこに置かない - **シート**: 詳細タブの中身の代わりに出る(タブの列と左の列はそのまま。どのタブ・小タブを押しても閉じる。ダッシュボードを閉じても開いたまま)。上に「細めの学習用に撮る」と「半目の読み直し(AI)を、いろんな目で賢くするための撮影だよ」、右に × の[閉じる]。[撮る]で開くたびに、前の終わり・失敗の画面は忘れる。画面は `contrib::screen` が決める: - - **同意**(`info.txt` に同意が無い・同意文の版〔`contrib::kConsentVersion`、今は 1〕が古い・眼鏡の答えが無いとき): 「「半目の読み直し(AI)」が細めや半目をもっと正しく見分けられるように、学習用の目の映像を録るよ。協力するかどうかは自由です。」、見出しつきで「録るもの」(目のカメラの映像を 1 回約 1 分半〔1 本 約 2.7 GB〕。送るまでは Frame の中だけ)・「使いみち」(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く)・「消してほしいとき」(言えば消す。もう配った AI に入った分は取り出せない)、警告色の枠で送り方の注意「目の映像は GitHub の issue や公開の場所に上げないでね。開発者に非公開で送ってね(送り方は開発者が伝えます)。」(連絡先はアプリに入れない)、灰色で「画面が白や黒に光ることはないよ(光なしで撮るよ)。」(光の注意は要らない)。[やめる]と[同意して次へ]。消したあとは上に緑で「録画を消したよ」 + - **同意**(`info.txt` に同意が無い・同意文の版〔`contrib::kConsentVersion`、今は 1〕が古い・眼鏡の答えが無いとき): 「「半目の読み直し(AI)」が細めや半目をもっと正しく見分けられるように、学習用の目の映像を録るよ。協力するかどうかは自由です。」、見出しつきで「録るもの」(目のカメラの映像を 1 回約 1 分半〔1 本 約 2.7 GB〕と、そのときの校正の数字と設定。送るまでは Frame の中だけ)・「使いみち」(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く)・「消してほしいとき」(言えば消す。もう配った AI に入った分は取り出せない)、警告色の枠で送り方の注意「目の映像は GitHub の issue や公開の場所に上げないでね。開発者に非公開で送ってね(送り方は開発者が伝えます)。」(連絡先はアプリに入れない)、灰色で「画面が白や黒に光ることはないよ(光なしで撮るよ)。」(光の注意は要らない)。[やめる]と[同意して次へ]。消したあとは上に緑で「録画を消したよ」 - **眼鏡・インサート**: 「HMD の中で、眼鏡や度付きインサートを使ってる?」「学習のときに分けて見るためだけに使うよ。録画ごとに info.txt に書くよ」、同じ重さの 4 つ[使ってない][眼鏡][度付きインサート][答えない]と[戻る]。答えたら、そのとき同意と答えを `info.txt` に書く(フォルダが無ければ作る) - **撮る前**: 「細めの学習用の撮影(約 1 分半)」と点つきで「最初の 10 秒は、普通に開けて正面を見て待ってね」「ビープと画面の指示に合わせて、閉じる・普通・細める・普通・見開くを 3 回、そのあと上・下・左・右を見るよ」「細めるは、まぶしいときみたいに目を細める(閉じない)」「左・右は自分から見た向き。顔は正面のまま、目だけ動かしてね」「画面が白や黒に光ることはないよ」、録ったものがあれば灰色で「これまで 3 本・合計 8.2 GB」。ボタンの上に灰色で「空き容量 23.2 GB(1 本 約 2.7 GB)」(足りなければ赤で「空きが足りないよ(約 3.8 GB 要る)」、eyecam-rec がほかのことをしていれば「目のカメラがほかのことをしているあいだは待ってね」)、[撮影をはじめる(約 1 分半)]。押せるのは同意が済んで、eyecam-rec が待機中(かエラー)・コマンドの返事待ちでない・まとめていない・1 本ぶん(目のカメラの毎秒の枚数から。分からなければ 90)と 1 GiB が空いているとき(`contrib::startAllowed`) - **録画中**: eyecam-rec が探している・録っているあいだは目の撮影の画面(`drawRun`)のまま、見出しだけ「細めの学習用に撮る」(その前後のつなぎ目は見出しと「送っています…」だけ)。ステップのチップは `protocol_contrib.txt` から(23 ステップ)。左右を見る段は「左の耳の方を見て」「右の耳の方を見て」(チップは「左を見る」「右を見る」)。シートを閉じていても、詳細タブの録画の画面は同じ見出しになる - - **録れなかった**: 赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった・ファイルが足りなかった〔そのファイル名〕)、途中までのフォルダがあれば灰色で「途中までの分は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度撮る] - - **終わり**(撮れたあと、または[録ったもの]): 撮れたあとは緑のチェックと「撮れたよ。ありがとう!」、[録ったもの]なら「録ったもの」。「これまで 4 本・合計 10.9 GB」、灰色で「場所: ~/eyecam/contrib/」、撮れたあとは「別の日や、かぶり直したあとにもう一度押してくれると、もっと助かるよ」、送り方の注意の枠。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.7 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(4 本・6.3 GB)」とその下にファイル名(それより本数が増えるまでは押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。その下に灰色で「まとめたファイルは .tar.zst(7-Zip などで開けるよ)」(zstd が無くて .tar になるときは出さない)。まとめるときにファイルが足りなかった録画は赤で「rec_… はファイルが足りないので入れなかったよ。もう一度撮ってね」。下の左に[録画を消す]、右に[閉じる] + - **録れなかった**: 赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった・ファイルが足りなかった〔そのファイル名〕・ファイルが途中で切れていた〔「空き容量を確かめて」〕・Valve の目のトラッキングの値が足りなかった〔フレームの何 % にあったか。「SteamVR の目のトラッキングが動いてるか確かめて」〕。どれも「もう一度撮ってね」)、録画のフォルダがあれば灰色で「この録画は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度撮る] + - **終わり**(撮れたあと、または[録ったもの]): 撮れたあとは緑のチェックと「撮れたよ。ありがとう!」、[録ったもの]なら「録ったもの」。「これまで 4 本・合計 10.9 GB」、灰色で「場所: ~/eyecam/contrib/」、撮れたあとは「別の日や、かぶり直したあとにもう一度押してくれると、もっと助かるよ」、送り方の注意の枠。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.7 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(4 本・6.3 GB)」とその下にファイル名(それより本数が増えるまでは押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。その下に灰色で「まとめたファイルは .tar.zst(7-Zip などで開けるよ)」(zstd が無くて .tar になるときは出さない)。まとめるときに学習に使えなかった録画は赤で「rec_… は学習に使えない形だったので入れなかったよ。もう一度撮ってね」。下の左に[録画を消す]、右に[閉じる] - **消す確認**: 赤い枠のカードで「撮った録画を全部消す?」「~/eyecam/contrib/ を中身ごと(10.9 GB)消します。元に戻せないよ。開発者に送ったあとで消してね。」、[やめる](アクセント)と[消す]。消している間は「消しています…」、消したら同意の画面(同意も `info.txt` と一緒に消える) -- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib/info.txt` を読む。[撮影をはじめる]は `ctl.sock` に `start contrib` を送り、そのときの値を覚える(下の `info.txt`)。終わりは `status.json` だけで見る(`contrib::RunWatch`): `contrib` の `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだもので `frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろっていれば `rename` でフォルダへ移し、`~/eyecam/calib_日時/calib_result.json` のいちばん新しいもの(撮り始めたときに読んだもの)を `rec_日時.calib_result.json` として隣に置く。足りない・止まった・失敗したものは `aborted/` へ(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない)。パネルが録画の途中で起動し直したときは追わない(その録画は `~/eyecam/rec_日時/` に残る) -- **info.txt**(`~/eyecam/contrib/info.txt`): 初めに何のファイルかのコメントと `format=2`、そのあと出来事ごとに節を書き足す。`[consent]`(`time`・`version`・`eyewear`: `none` / `glasses` / `inserts` / `no_answer`。最後の節が効く)、`[recording]`(`rec`・`protocol`・`start`・`start_unix`・`end_unix`・`bytes`・`app_version`・`eyecam_rec_sha256`〔入っている eyecam-rec の SHA-256 の先頭 8 桁。git のハッシュは持っていない〕・`lid_veto`(`on` / `off` / `unknown`)・`widen`・`widen_sensitivity`・`camera_lids`・`eyewear`・`camera_fps`・`minutes_since_previous`(前の録画が終わってから何分。1 本目は `none`)・`headset_off`(そのあいだに HMD を外したか: `yes` / `no` / `unknown` / 1 本目は `none`)・`headset_off_count_start` / `_end`・`eyecam_started_unix`・`calib_result`)、`[aborted]`(`rec`・`why`)、`[packed]`(`file`・`recordings`・`bytes`)。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) +- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib/info.txt` を読む。[撮影をはじめる]は `ctl.sock` に `start contrib` を送り、そのときの値を覚える(下の `info.txt`)。終わりは `status.json` だけで見る(`contrib::RunWatch`): `contrib` の `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだもので学習に使えるものは `rename` でフォルダへ移し、撮り始めたときに読んだ `~/eyecam/calib_日時/calib_result.json` のいちばん新しいもの・`~/.config/eyecam/calib.json`(ユーザー校正)・`~/.config/eyecam/settings.json` の写しを、`rec_日時.calib_result.json`・`rec_日時.user_calib.json`・`rec_日時.settings.json` として隣に置く(無いものは `info.txt` に `none`)。学習に使える録画かは `contrib::checkRecording` が CSV の行と raw の大きさだけで見る(映像は読まない。3 分の録画でも数十 ms): `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` の 6 つがそろっている、目ごとの raw の大きさが `frames.csv` のその目の行数 × 1 フレーム(`meta.txt` の `frame_bytes`、無ければ 160000 B)とちょうど同じで 1 枚以上ある(ディスクがいっぱいで途中で切れた raw をはじく)、`frames.csv` のフレームの 9 割以上に、`t_cam` の前後 0.05 秒以内の使える Valve のサンプル(`valve.csv` の `producer_state` が 1 で `open_l`・`open_r` が数。`sample_time` は `t_cam` と同じ時計)がある(ラベルは Valve の開き具合からも作るので)。Frame の自分の録画 13 本はどれも通った。使えない・止まった・失敗したものは `aborted/` へ(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない)。パネルが録画の途中で起動し直したときは追わない(その録画は `~/eyecam/rec_日時/` に残る) +- **info.txt**(`~/eyecam/contrib/info.txt`): 初めに何のファイルかのコメントと `format=2`、そのあと出来事ごとに節を書き足す。`[consent]`(`time`・`version`・`eyewear`: `none` / `glasses` / `inserts` / `no_answer`。最後の節が効く)、`[recording]`(`rec`・`protocol`・`start`・`start_unix`・`end_unix`・`bytes`・`app_version`・`eyecam_rec_sha256`〔入っている eyecam-rec の SHA-256 の先頭 8 桁。git のハッシュは持っていない〕・`lid_veto`(`on` / `off` / `unknown`)・`widen`・`widen_sensitivity`・`camera_lids`・`eyewear`・`camera_fps`・`minutes_since_previous`(前の録画が終わってから何分。1 本目は `none`)・`headset_off`(そのあいだに HMD を外したか: `yes` / `no` / `unknown` / 1 本目は `none`)・`headset_off_count_start` / `_end`・`eyecam_started_unix`・`calib_result`・`user_calib`・`settings`)、`[aborted]`(`rec`・`why`: `stopped` / `failed` / `missing cues.csv` / `cut short: eye_R.raw` / `Valve's samples for only 40% of the frames`)、`[packed]`(`file`・`recordings`・`bytes`)。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) - **外したかの見分け**: eyecam-rec の `status.json` の `headset_off_count`(起動してから、目の映像が 10 秒以上止まったか近接センサーが 10 秒以上「かぶっていない」になって、そのあとまたかぶった回数。サスペンドも数える時計で測る。tools/eyecam の README)と `started_unix` で決める。前の録画の終わりと今回の始まりで eyecam-rec が同じ起動(`started_unix` が同じ)なら、数が増えていれば `yes`、同じなら `no`。eyecam-rec が起動し直していた・古い eyecam-rec で値が無いときは `unknown`。続けて 2 回押しただけの録画を別のかぶりとして数えないため -- **まとめる**(`contrib::Worker`、別のスレッド、nice 10): `info.txt`、4 つのファイルがそろった録画とその校正結果だけを ustar の tar(中のフォルダ名は `contrib/`。8 GiB 以上の大きさは GNU の base-256)にして、zstd があれば `zstd -6 -T2` に通し、`contrib_日時.tar.zst`(無ければ `.tar`)にする。Frame の目の映像 2 GB で測ると、zstd -3 は 0.62 倍・7.7 秒、-6 は 0.58 倍・20 秒、-9 は 0.56 倍・43 秒、-12 は 0.55 倍・90 秒、zip -6 は 0.60 倍・166 秒、gzip -1 は 0.62 倍・19 秒だったので、-6 にした(SteamOS には zstd も tar も zip も入っている。7z は無い)。`.part` に書いて fsync してから名前を変え、失敗したら `.part` を消す。始める前に空きを確かめる(録画の 0.7 倍〔zstd なし: 1 倍〕と 1 GiB)。できたら `[packed]` を書き足し、前にまとめたファイル(本数の少ないもの)は消す。元の録画には触らない +- **まとめる**(`contrib::Worker`、別のスレッド、nice 10): 別のスレッドの中で録画を同じ条件(`checkRecording`)で確かめ直してから、`info.txt`、使える録画と隣の写しだけを ustar の tar(中のフォルダ名は `contrib/`。8 GiB 以上の大きさは GNU の base-256)にして、zstd があれば `zstd -6 -T2` に通し、`contrib_日時.tar.zst`(無ければ `.tar`)にする。Frame の目の映像 2 GB で測ると、zstd -3 は 0.62 倍・7.7 秒、-6 は 0.58 倍・20 秒、-9 は 0.56 倍・43 秒、-12 は 0.55 倍・90 秒、zip -6 は 0.60 倍・166 秒、gzip -1 は 0.62 倍・19 秒だったので、-6 にした(SteamOS には zstd も tar も zip も入っている。7z は無い)。`.part` に書いて fsync してから名前を変え、失敗したら `.part` を消す。始める前に空きを確かめる(録画の 0.7 倍〔zstd なし: 1 倍〕と 1 GiB)。できたら `[packed]` を書き足し、前にまとめたファイル(本数の少ないもの)は消す。元の録画には触らない - **消す**: 確認のあと、`~/eyecam/contrib` だけを(中のリンクはたどらずに)丸ごと消す ## ほかのアプリから動かす(--action) @@ -410,7 +410,7 @@ systemctl --user daemon-reload | `src/config.*` | `config.json` の項目の表(型・既定値・範囲・刻み)、読み書き(一時ファイル → fsync → rename) | | `src/status.*` | `status.json` の読み込みと、本体が動いているかの判断 | | `src/eye_preview.*` | 目のプレビュー: どの値で両目を描くか、まぶた・瞳孔・視線の形、eyecam-rec の `preview` ファイルの読み方(mmap・seqlock・確かめ)、映像が出せるか、`preview on` / `off` の送り方、`--fake-preview` の作り物 | -| `src/contrib.*` | 「細めの学習用に撮る」: 1 本の大きさ、`info.txt` の節の読み書き、録画の終わり(`status.json` から)、4 つのファイルの確認と移動、外したかの見分け、tar と zstd でまとめる・消すスレッド、画面の決め方 | +| `src/contrib.*` | 「細めの学習用に撮る」: 1 本の大きさ、`info.txt` の節の読み書き、録画の終わり(`status.json` から)、学習に使えるかの確認(6 つのファイル・raw の大きさ・Valve のサンプル)と移動、外したかの見分け、tar と zstd でまとめる・消すスレッド、画面の決め方 | | `src/eyecam.*` | 目の撮影タブ(開発用): eyecam-rec の `status.json` の読み込み、タブと全面の光を出す条件と光のフェード、光の注意(始める前の確認と送るコマンド)、指示の文、`ctl.sock` への送信 | | `src/action_socket.*` | `--action`: 動作ごとの判断と終了コード、1 行のやりとり、コマンドの側(つないで 2 秒待つ)、パネルの側のソケット(ノンブロッキング、uid の確認) | | `src/autostart.*` | `systemctl --user` で自動起動を読む・切り替えるワーカースレッド | diff --git a/panel/src/contrib.cpp b/panel/src/contrib.cpp index 1d04be7..2664f08 100644 --- a/panel/src/contrib.cpp +++ b/panel/src/contrib.cpp @@ -36,8 +36,10 @@ constexpr size_t kChunk = 1 << 20; constexpr int kNice = 10; /** The most of info.txt that is read (a section is under 1 KB). */ constexpr size_t kMaxInfoBytes = 4 << 20; -/** The most of a calibration result that is copied. */ +/** The most of a calibration result (or eyecam's calib.json, settings.json) that is copied. */ constexpr size_t kMaxCalibBytes = 1 << 20; +/** The most of a recording's CSV file that is read to check it (frames.csv of 3 minutes is about 2 MB). */ +constexpr size_t kMaxCsvBytes = 64 << 20; /** * Whether text from a position is "YYYY-MM-DD_HH-MM-SS". @@ -192,6 +194,35 @@ bool writeFile(const std::string& path, const std::string& text) { return true; } +/** + * Split a CSV line (eyecam-rec's: plain fields, no quotes). + * @param line the line + * @return its fields + */ +std::vector fields(const std::string& line) { + std::vector out; + size_t from = 0; + for (;;) { + const size_t comma = line.find(',', from); + out.push_back(line.substr(from, comma == std::string::npos ? std::string::npos : comma - from)); + if (comma == std::string::npos) break; + from = comma + 1; + } + if (!out.empty() && !out.back().empty() && out.back().back() == '\r') out.back().pop_back(); + return out; +} + +/** + * A column's place in a CSV header. + * @param header the header's fields + * @param name the column + * @return its index, or -1 + */ +int column(const std::vector& header, const char* name) { + const auto at = std::find(header.begin(), header.end(), name); + return at == header.end() ? -1 : static_cast(at - header.begin()); +} + /** * Put an octal number into a tar header field: digits, then a NUL. * @param field the field @@ -295,6 +326,8 @@ std::string recordingSection(const Recording& r) { out << "headset_off_count_end=" << r.offCountEnd << "\n"; out << "eyecam_started_unix=" << number(r.eyecamStarted > 0 ? r.eyecamStarted : NAN, 3) << "\n"; out << "calib_result=" << r.calib << "\n"; + out << "user_calib=" << r.userCalib << "\n"; + out << "settings=" << r.settings << "\n"; return out.str(); } @@ -356,6 +389,8 @@ void parseInfo(const std::string& text, Info& info) { if (key == "headset_off_count_end") r.offCountEnd = readInteger(value, -1); if (key == "eyecam_started_unix") r.eyecamStarted = readNumber(value, 0.0); if (key == "calib_result") r.calib = value; + if (key == "user_calib") r.userCalib = value; + if (key == "settings") r.settings = value; } else if (section == "aborted") { if (key == "rec") abortedRec = value; if (key == "why") info.aborted.push_back(abortedRec + " " + value); @@ -395,6 +430,11 @@ std::string defaultHome() { return std::string(home != nullptr ? home : "") + "/eyecam"; } +std::string defaultConfigDir() { + const char* home = std::getenv("HOME"); + return std::string(home != nullptr ? home : "") + "/.config/eyecam"; +} + bool appendInfo(const std::string& dir, const std::string& text, std::string& error) { const std::string path = dir + "/" + kInfoFile; const bool fresh = ::access(path.c_str(), F_OK) != 0; @@ -442,6 +482,85 @@ std::vector missingFiles(const std::string& dir) { return missing; } +CheckResult checkRecording(const std::string& dir) { + CheckResult result; + const std::vector missing = missingFiles(dir); + if (!missing.empty()) { + result.check = Check::Missing; + for (const std::string& file : missing) result.detail += (result.detail.empty() ? "" : ", ") + file; + return result; + } + // frames.csv: each eye's frames, and their camera times + std::string text; + file_io::read(dir + "/frames.csv", text, kMaxCsvBytes); + std::istringstream frames(text); + std::string line; + std::getline(frames, line); + const std::vector head = fields(line); + const int eyeAt = column(head, "eye"); + const int camAt = column(head, "t_cam"); + long long count[2] = {0, 0}; + std::vector times; + while (eyeAt >= 0 && camAt >= 0 && std::getline(frames, line)) { + const std::vector f = fields(line); + if (static_cast(f.size()) <= std::max(eyeAt, camAt)) continue; + if (f[eyeAt] != "L" && f[eyeAt] != "R") continue; + ++count[f[eyeAt] == "R" ? 1 : 0]; + times.push_back(readNumber(f[camAt], NAN)); + } + // Each raw file exactly that many frames (frame_bytes from meta.txt): one cut short when the disk ran full + long long frameBytes = kRawFrameBytes; + file_io::read(dir + "/meta.txt", text, kMaxCsvBytes); + std::istringstream meta(text); + while (std::getline(meta, line)) { + if (line.rfind("frame_bytes=", 0) == 0) frameBytes = readInteger(line.substr(12), kRawFrameBytes); + } + for (int eye = 0; eye < 2; ++eye) { + const std::string raw = eye == 0 ? "eye_L.raw" : "eye_R.raw"; + struct stat st {}; + if (::stat((dir + "/" + raw).c_str(), &st) != 0 || count[eye] == 0 || + static_cast(st.st_size) != count[eye] * frameBytes) { + result.check = Check::Truncated; + result.detail += (result.detail.empty() ? "" : ", ") + raw; + } + } + if (result.check != Check::Ok) return result; + // valve.csv: the usable samples' times, and how many frames have one near + file_io::read(dir + "/valve.csv", text, kMaxCsvBytes); + std::istringstream valve(text); + std::getline(valve, line); + const std::vector vhead = fields(line); + const int timeAt = column(vhead, "sample_time"); + const int stateAt = column(vhead, "producer_state"); + const int openL = column(vhead, "open_l"); + const int openR = column(vhead, "open_r"); + const int last = std::max({timeAt, stateAt, openL, openR}); + std::vector samples; + while (std::min({timeAt, stateAt, openL, openR}) >= 0 && std::getline(valve, line)) { + const std::vector f = fields(line); + if (static_cast(f.size()) <= last || f[stateAt] != "1") continue; + const double t = readNumber(f[timeAt], NAN); + if (std::isfinite(t) && std::isfinite(readNumber(f[openL], NAN)) && std::isfinite(readNumber(f[openR], NAN))) { + samples.push_back(t); + } + } + std::sort(samples.begin(), samples.end()); + long long paired = 0; + for (const double t : times) { + if (!std::isfinite(t)) continue; + const auto next = std::lower_bound(samples.begin(), samples.end(), t); + const bool after = next != samples.end() && *next - t <= kValvePairSec; + const bool before = next != samples.begin() && t - *(next - 1) <= kValvePairSec; + paired += after || before ? 1 : 0; + } + const double share = times.empty() ? 0.0 : static_cast(paired) / static_cast(times.size()); + if (share < kValveShare) { + result.check = Check::Valve; + result.detail = std::to_string(static_cast(std::floor(share * 100))) + "%"; + } + return result; +} + bool takeRecording(const std::string& home, const std::string& dir, const std::string& sessionDir, bool aborted, std::string& name, std::string& error) { const std::string base = baseName(sessionDir); @@ -595,14 +714,16 @@ bool packEntries(const std::string& dir, const Info& info, std::vector& e } for (const Recording& r : info.recordings) { const std::string rec = dir + "/" + r.rec; - // (one lacking a file, or gone, is left out and named: it is to be recorded again) - if (!isRealDir(rec) || !missingFiles(rec).empty() || ::stat(rec.c_str(), &st) != 0) { + // (one that can't be used, or gone, is left out and named: it is to be recorded again) + if (!isRealDir(rec) || checkRecording(rec).check != Check::Ok || ::stat(rec.c_str(), &st) != 0) { incomplete.push_back(r.rec); continue; } entries.push_back({rec, top + "/" + r.rec + "/", 0, st.st_mtime, true}); for (const std::string& name : names(rec)) file(r.rec + "/" + name); - if (r.calib != "none" && !r.calib.empty()) file(r.calib); + for (const std::string& copy : {r.calib, r.userCalib, r.settings}) { + if (!copy.empty() && copy != "none") file(copy); + } } for (const Entry& entry : entries) { if (entry.name.size() >= 100) { @@ -668,22 +789,19 @@ void Worker::reap() { cancel_ = false; } -bool Worker::pack(const std::string& dir, const std::vector& entries, const std::string& name, int count, - bool compressed) { +bool Worker::pack(const std::string& dir, const Info& info, const std::string& name, bool compressed) { if (progress().running) return false; reap(); Progress start; start.job = Job::Pack; start.running = true; start.output = name; - start.count = count; - for (const Entry& entry : entries) start.total += entry.size; { std::lock_guard lock(mutex_); progress_ = start; reported_ = true; } - thread_ = std::thread([this, dir, entries, name, compressed]() { runPack(dir, entries, name, compressed); }); + thread_ = std::thread([this, dir, info, name, compressed]() { runPack(dir, info, name, compressed); }); return true; } @@ -729,10 +847,25 @@ void Worker::finish(bool ok, const std::string& error) { reported_ = false; } -void Worker::runPack(const std::string& dir, const std::vector& entries, const std::string& name, - bool compressed) { +void Worker::runPack(const std::string& dir, const Info& info, const std::string& name, bool compressed) { // Below the panel's own loop and VR (Linux sets a thread's niceness on its own) ::setpriority(PRIO_PROCESS, static_cast(::syscall(SYS_gettid)), kNice); + // Each recording checked again (its CSV files read): what can't be used stays out + std::vector entries; + std::vector incomplete; + std::string listError; + const bool listed = packEntries(dir, info, entries, incomplete, listError); + const int count = static_cast(info.recordings.size() - incomplete.size()); + { + std::lock_guard lock(mutex_); + progress_.incomplete = incomplete; + progress_.count = count; + for (const Entry& entry : entries) progress_.total += entry.size; + } + if (!listed || count == 0) { + finish(false, listed ? std::string("no recording can be used") : listError); + return; + } const std::string final = dir + "/" + name; const std::string part = final + ".part"; const int out = ::open(part.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); @@ -879,13 +1012,14 @@ void Worker::runPack(const std::string& dir, const std::vector& entries, finish(true, ""); } -void Session::load(View& view, const std::string& home) { +void Session::load(View& view, const std::string& home, const std::string& configDir) { const Progress progress = view.progress; const bool deleted = view.deleted; view = View(); view.loaded = true; view.home = home; view.dir = home + "/" + kFolderName; + view.configDir = configDir; view.progress = progress; view.deleted = deleted; view.compress = zstdAvailable(); @@ -952,7 +1086,12 @@ void Session::started(View& view, const eyecam::View& eyecam, const Snapshot& sn r.headsetOff = headsetOffBetween(before, r.offCountStart, r.eyecamStarted); } pending_ = r; + // The calibrations and settings then, copied beside it once it is recorded calibText_ = newestCalibResult(view.home); + userCalibText_.clear(); + settingsText_.clear(); + file_io::read(view.configDir + "/calib.json", userCalibText_, kMaxCalibBytes); + file_io::read(view.configDir + "/settings.json", settingsText_, kMaxCalibBytes); } bool Session::replied(View& view, const eyecam::Reply& reply) { @@ -999,6 +1138,7 @@ bool Session::follow(View& view, const eyecam::View& eyecam, double nowUnix, dou Progress finished; if (worker_.takeFinished(finished)) { changed = true; + if (finished.job == Job::Pack) view.incomplete = finished.incomplete; if (finished.job == Job::Pack && finished.ok) { // (the pack before it holds fewer: it goes; the recordings stay) const std::string before = view.info.packed; @@ -1023,18 +1163,7 @@ bool Session::follow(View& view, const eyecam::View& eyecam, double nowUnix, dou bool Session::pack(View& view, std::time_t now) { if (!view.exists || view.running || view.info.recordings.empty()) return false; - std::vector entries; - std::string error; - if (!packEntries(view.dir, view.info, entries, view.incomplete, error)) { - view.progress = Progress(); - view.progress.job = Job::Pack; - view.progress.finished = true; - view.progress.error = error; - return false; - } - const int count = static_cast(view.info.recordings.size() - view.incomplete.size()); - if (count == 0) return false; - const bool ok = worker_.pack(view.dir, entries, packName(now, view.compress), count, view.compress); + const bool ok = worker_.pack(view.dir, view.info, packName(now, view.compress), view.compress); view.progress = worker_.progress(); return ok; } @@ -1063,10 +1192,10 @@ void Session::ended(View& view, const RunEnd& end, const eyecam::View& eyecam, d run_.end(); command_.clear(); view.running = false; - // Done, and with every file it needs: into the folder, with its section and its calibration result - std::vector missing; - if (end.outcome == Outcome::Done) missing = missingFiles(end.dir); - if (end.outcome == Outcome::Done && missing.empty()) { + // Done, and usable: into the folder, with its section and the copies beside it + CheckResult check; + if (end.outcome == Outcome::Done) check = checkRecording(end.dir); + if (end.outcome == Outcome::Done && check.check == Check::Ok) { std::string name; std::string error; if (takeRecording(view.home, view.dir, end.dir, false, name, error)) { @@ -1077,10 +1206,13 @@ void Session::ended(View& view, const RunEnd& end, const eyecam::View& eyecam, d const eyecam::Status& s = eyecam.status; r.offCountEnd = s.hasHeadsetOff && std::fabs(s.startedUnix - r.eyecamStarted) <= 1.0 ? s.headsetOffCount : -1; - r.calib = "none"; - if (!calibText_.empty() && writeFile(view.dir + "/" + name + ".calib_result.json", calibText_)) { - r.calib = name + ".calib_result.json"; - } + const auto copy = [&](const std::string& text, const char* suffix) { + const std::string file = name + suffix; + return !text.empty() && writeFile(view.dir + "/" + file, text) ? file : std::string("none"); + }; + r.calib = copy(calibText_, ".calib_result.json"); + r.userCalib = copy(userCalibText_, ".user_calib.json"); + r.settings = copy(settingsText_, ".settings.json"); append(view, recordingSection(r)); view.totalBytes = folderBytes(view.dir); view.justDone = true; @@ -1092,14 +1224,17 @@ void Session::ended(View& view, const RunEnd& end, const eyecam::View& eyecam, d log += "recorded, but " + error + "\n"; return; } - // Didn't finish, or lacks a file: its part (if any) goes to aborted/, and it can be recorded again + // Didn't finish, or can't be used: its part (if any) goes to aborted/, and it can be recorded again view.failure = {true, end.why, end.message, end.messageEn, ""}; std::string why = end.outcome == Outcome::Stopped ? "stopped" : "failed"; - if (!missing.empty()) { - std::string list; - for (const std::string& file : missing) list += (list.empty() ? "" : ", ") + file; - view.failure = {true, Why::Incomplete, list, list, ""}; - why = "missing " + list; + if (end.outcome == Outcome::Done) { + const Why reason = check.check == Check::Missing ? Why::Incomplete + : check.check == Check::Truncated ? Why::Truncated + : Why::Valve; + view.failure = {true, reason, check.detail, check.detail, ""}; + why = check.check == Check::Missing ? "missing " + check.detail + : check.check == Check::Truncated ? "cut short: " + check.detail + : "Valve's samples for only " + check.detail + " of the frames"; } if (!end.dir.empty()) { std::string name; diff --git a/panel/src/contrib.h b/panel/src/contrib.h index 75ed088..7313f5e 100644 --- a/panel/src/contrib.h +++ b/panel/src/contrib.h @@ -34,10 +34,16 @@ constexpr int kConsentVersion = 1; constexpr const char* kFolderName = "contrib"; /** Its file about them (a section added for each event). */ constexpr const char* kInfoFile = "info.txt"; -/** Where a recording that didn't finish (or lacks a file) goes in it: never packed, deleted with it. */ +/** Where a recording that didn't finish (or can't be used) goes in it: never packed, deleted with it. */ constexpr const char* kAbortedDir = "aborted"; /** The files every recording needs for training (the labels come from cues.csv and Valve's openness). */ -constexpr const char* kRequiredFiles[] = {"frames.csv", "valve.csv", "cues.csv", "meta.txt"}; +constexpr const char* kRequiredFiles[] = {"eye_L.raw", "eye_R.raw", "frames.csv", "valve.csv", "cues.csv", "meta.txt"}; +/** A raw frame's size when meta.txt doesn't say (frame_bytes: 400x400 grey, or 512x400 with --full-width). */ +constexpr long long kRawFrameBytes = 160000; +/** At least this share of the frames needs a usable Valve sample near it (the labels use Valve's openness)... */ +constexpr double kValveShare = 0.9; +/** ...within this long of its camera time (s; Valve's samples come about 90 times a second, on the same clock). */ +constexpr double kValvePairSec = 0.05; /** What eyecam-rec writes per frame and eye (a 400x400 grey frame, its header, its rows), as eyecam-rec estimates. */ constexpr double kFrameBytes = 160300.0; /** The eye cameras' usual rate, for the estimate while eyecam-rec doesn't say. */ @@ -93,6 +99,8 @@ struct Recording { long long offCountEnd = -1; ///< ...and at the end double eyecamStarted = 0.0; ///< eyecam-rec's started_unix (the count is per run; 0 = unknown) std::string calib; ///< the calibration's result copied beside it (".calib_result.json"; "none") + std::string userCalib; ///< eyecam's calib.json copied beside it (".user_calib.json"; "none") + std::string settings; ///< eyecam's settings.json copied beside it (".settings.json"; "none") }; /** info.txt as read: the consent and every recording. */ @@ -219,6 +227,12 @@ std::string localTime(std::time_t when, bool zone); */ std::string defaultHome(); +/** + * Where eyecam keeps its user calibration and settings ($HOME/.config/eyecam: calib.json, settings.json). + * @return the folder + */ +std::string defaultConfigDir(); + /** * Add text to the end of the folder's info.txt (made with headerText first), on the disk before it returns. * @param dir the folder @@ -249,6 +263,31 @@ long long folderBytes(const std::string& path); */ std::vector missingFiles(const std::string& dir); +/** Whether a recording can be used for training. */ +enum class Check { + Ok, + Missing, ///< a file of kRequiredFiles isn't there + Truncated, ///< an eye's raw file doesn't hold as many frames as frames.csv lists for it (the disk ran full) + Valve, ///< under kValveShare of the frames have a usable Valve sample near them (Valve's tracking off?) +}; + +/** checkRecording's answer. */ +struct CheckResult { + Check check = Check::Ok; + std::string detail; ///< the files missing, the raw files cut short ("eye_L.raw"), or the share paired ("42%") +}; + +/** + * Whether a recording can be used for training, from its CSV files and its raw files' sizes alone (never the + * frames): all of kRequiredFiles there; each eye's raw file exactly as many frames (meta.txt's frame_bytes each, else + * kRawFrameBytes) as frames.csv has rows for that eye, and at least one; and at least kValveShare of frames.csv's + * frames with a usable Valve sample in valve.csv (producer_state 1, both openness values numbers) within + * kValvePairSec of their t_cam (sample_time is on the same clock). + * @param dir its folder + * @return the answer + */ +CheckResult checkRecording(const std::string& dir); + /** * Move a recording eyecam-rec has finished into the contribution's folder (aborted/ for one that didn't finish or * lacks a file). Only one of eyecam-rec's own folders, right in home ("rec_"), is moved. @@ -289,7 +328,7 @@ enum class Outcome { }; /** Why a try didn't finish (the screen says it). */ -enum class Why { None, Stopped, Error, Refused, Gone, NoStart, Missing, Move, Incomplete }; +enum class Why { None, Stopped, Error, Refused, Gone, NoStart, Missing, Move, Incomplete, Truncated, Valve }; /** A recording's end. */ struct RunEnd { @@ -344,7 +383,7 @@ class RunWatch { struct Failure { bool shown = false; Why why = Why::None; - std::string message; ///< eyecam-rec's (Japanese), the reason, or the missing files + std::string message; ///< eyecam-rec's (Japanese), the reason, or checkRecording's detail std::string messageEn; std::string kept; ///< where its part went ("aborted/rec_..."; "" = none) }; @@ -364,6 +403,7 @@ struct Progress { std::string output; ///< the packed file's name in the folder long long outputBytes = 0; int count = 0; ///< the recordings packed + std::vector incomplete; ///< recordings it left out (checkRecording said no) }; /** The contribution as the panel shows it (PanelModel::contrib; the loop fills it in). */ @@ -371,6 +411,7 @@ struct View { bool loaded = false; ///< looked for since the panel started std::string home; ///< eyecam-rec's folder (defaultHome) std::string dir; ///< the contribution's folder (home/kFolderName) + std::string configDir; ///< eyecam's (defaultConfigDir): its calib.json and settings.json are copied bool exists = false; ///< ...is there Info info; ///< its info.txt as read long long totalBytes = 0; ///< everything in it (recordings, aborted/, a pack), as last counted @@ -380,7 +421,7 @@ struct View { bool running = false; ///< a recording of it runs bool justDone = false; ///< one has just been recorded (the end's screen, until the sheet is opened again) Failure failure; ///< the last try, when it didn't finish - std::vector incomplete; ///< recordings the last pack left out (a file missing) + std::vector incomplete; ///< recordings the last pack left out (checkRecording said no) Progress progress; ///< packing or deleting bool compress = true; ///< zstd is there (the pack is .tar.zst; else .tar) bool deleted = false; ///< it was just deleted (the first screen says so) @@ -431,8 +472,9 @@ struct Entry { }; /** - * What the pack holds: the folder, info.txt, and each recording with all of kRequiredFiles (its files sorted by name) - * with its calibration result. Not aborted/ or a pack; a recording lacking a file is left out and named. + * What the pack holds: the folder, info.txt, and each recording checkRecording passes (its files sorted by name) with + * the copies beside it (calibration result, user calibration, settings). Not aborted/ or a pack; a recording that + * doesn't pass is left out and named. * @param dir the contribution's folder * @param info its info.txt as read * @param entries filled in @@ -477,16 +519,15 @@ class Worker { Worker& operator=(const Worker&) = delete; /** - * Pack into one file in the folder (tar, through zstd at kZstdLevel when compressed). + * Pack into one file in the folder (tar, through zstd at kZstdLevel when compressed), checking each recording + * again first (packEntries, on the thread: it reads their CSV files). * @param dir the folder - * @param entries what goes in (packEntries) + * @param info its info.txt as read * @param name the file's name (packName) - * @param count the recordings in it * @param compressed through zstd * @return false if a job runs */ - bool pack(const std::string& dir, const std::vector& entries, const std::string& name, int count, - bool compressed); + bool pack(const std::string& dir, const Info& info, const std::string& name, bool compressed); /** * Delete the contribution's folder (only home/kFolderName itself, a real folder). @@ -519,12 +560,11 @@ class Worker { /** * The pack's thread. * @param dir the folder - * @param entries what goes in it + * @param info its info.txt as read * @param name the file's name * @param compressed through zstd */ - void runPack(const std::string& dir, const std::vector& entries, const std::string& name, - bool compressed); + void runPack(const std::string& dir, const Info& info, const std::string& name, bool compressed); /** * The end of a job. @@ -553,8 +593,9 @@ class Session { * Read the folder (home/kFolderName) and its info.txt. * @param view filled in * @param home eyecam-rec's folder + * @param configDir eyecam's settings' folder */ - void load(View& view, const std::string& home); + void load(View& view, const std::string& home, const std::string& configDir = defaultConfigDir()); /** * The consent and the answer about glasses: the folder (made if needed), and their section in info.txt. @@ -624,6 +665,8 @@ class Session { std::string command_; ///< the start waiting for its reply ("" = none) Recording pending_; ///< the recording running, as its section will say std::string calibText_; ///< the calibration result at its start + std::string userCalibText_; ///< ...eyecam's calib.json then + std::string settingsText_; ///< ...and its settings.json double freeAt_ = -1e9; ///< when the free space was last read (boot seconds) /** diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index 6da8f48..36383ca 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -891,7 +891,8 @@ UiText makeJapanese() { t.contribLead = "「半目の読み直し(AI)」が細めや半目をもっと正しく見分けられるように、学習用の目の映像を録るよ。" "協力するかどうかは自由です。"; t.contribWhatTitle = "録るもの"; - t.contribWhatFormat = "目のカメラの映像を 1 回約 1 分半(1 本 約 %s)。送るまでは、この Frame の中だけにあるよ。"; + t.contribWhatFormat = "目のカメラの映像を 1 回約 1 分半(1 本 約 %s)と、そのときの校正の数字と設定。" + "送るまでは、この Frame の中だけにあるよ。"; t.contribUseTitle = "使いみち"; t.contribUse = "細めと半目の見分けの調整と、AI の学習だけに使います。アプリに入るのは学習した AI だけで、" "映像は入りません。映像は開発者の PC にだけ置きます。"; @@ -931,8 +932,11 @@ UiText makeJapanese() { t.contribWhyNoStart = "撮影が始まらなかったよ"; t.contribWhyMissing = "録画のフォルダが見つからなかったよ"; t.contribWhyMoveFormat = "録画を移せなかった: %s"; - t.contribWhyIncompleteFormat = "録画のファイルが足りなかったよ(%s)"; - t.contribKept = "途中までの分は aborted/ に入れたよ(送らなくて大丈夫)"; + t.contribWhyIncompleteFormat = "録画のファイルが足りなかったよ(%s)。もう一度撮ってね"; + t.contribWhyTruncatedFormat = "録画のファイルが途中で切れていたよ(%s)。空き容量を確かめて、もう一度撮ってね"; + t.contribWhyValveFormat = "Valve の目のトラッキングの値が、フレームの %s にしかなかったよ。" + "SteamVR の目のトラッキングが動いてるか確かめて、もう一度撮ってね"; + t.contribKept = "この録画は aborted/ に入れたよ(送らなくて大丈夫)"; t.contribRetry = "もう一度撮る"; t.contribDoneTitle = "撮れたよ。ありがとう!"; t.contribManageTitle = "録ったもの"; @@ -946,7 +950,7 @@ UiText makeJapanese() { t.contribPackedFormat = "できたよ(%s 本・%s)"; t.contribPackFailedFormat = "まとめられなかった: %s"; t.contribPackNoSpaceFormat = "まとめるには空きが足りないよ(約 %s 要る)"; - t.contribIncompleteFormat = "%s はファイルが足りないので入れなかったよ。もう一度撮ってね"; + t.contribIncompleteFormat = "%s は学習に使えない形だったので入れなかったよ。もう一度撮ってね"; t.contribDelete = "録画を消す"; t.contribClose = "閉じる"; t.contribDeleteTitle = "撮った録画を全部消す?"; @@ -1941,8 +1945,8 @@ UiText makeEnglish() { t.contribLead = "Eye video to train the \"Half-shut check (AI)\" to tell squints and half-shut eyes apart better. " "Taking part is entirely up to you."; t.contribWhatTitle = "What"; - t.contribWhatFormat = "Eye camera video, about 1.5 minutes each time (about %s each). It stays on this Frame until " - "you send it."; + t.contribWhatFormat = "Eye camera video, about 1.5 minutes each time (about %s each), with the calibration's " + "numbers and the settings then. It stays on this Frame until you send it."; t.contribUseTitle = "How it's used"; t.contribUse = "Only to tune the squint and half-shut check and train its AI. Only the trained AI goes into the " "app, never the video. The video is kept on the developer's own PCs."; @@ -1983,8 +1987,11 @@ UiText makeEnglish() { t.contribWhyNoStart = "The recording didn't start"; t.contribWhyMissing = "The recording's folder wasn't found"; t.contribWhyMoveFormat = "Couldn't move the recording: %s"; - t.contribWhyIncompleteFormat = "The recording lacks files (%s)"; - t.contribKept = "What was recorded is in aborted/ (no need to send it)"; + t.contribWhyIncompleteFormat = "The recording lacks files (%s). Please record again"; + t.contribWhyTruncatedFormat = "The recording's files were cut short (%s). Check the free space and record again"; + t.contribWhyValveFormat = "Valve's eye tracking values were there for only %s of the frames. Check that " + "SteamVR's eye tracking is on and record again"; + t.contribKept = "This recording went to aborted/ (no need to send it)"; t.contribRetry = "Record again"; t.contribDoneTitle = "Recorded. Thank you!"; t.contribManageTitle = "Recordings"; @@ -1998,7 +2005,7 @@ UiText makeEnglish() { t.contribPackedFormat = "Done (%s recordings, %s)"; t.contribPackFailedFormat = "Couldn't pack it: %s"; t.contribPackNoSpaceFormat = "Not enough space to pack it (about %s needed)"; - t.contribIncompleteFormat = "%s lacks files and was left out. Please record again"; + t.contribIncompleteFormat = "%s couldn't be used for training and was left out. Please record again"; t.contribDelete = "Delete the recordings"; t.contribClose = "Close"; t.contribDeleteTitle = "Delete all these recordings?"; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index b8585f1..4f99124 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -928,6 +928,8 @@ struct UiText { const char* contribWhyMissing; const char* contribWhyMoveFormat; ///< (%s: why) const char* contribWhyIncompleteFormat; ///< (%s: the files missing) + const char* contribWhyTruncatedFormat; ///< (%s: the raw files cut short) + const char* contribWhyValveFormat; ///< (%s: the share of frames with Valve's values, "42%") const char* contribKept; ///< ...its part went to aborted/ const char* contribRetry; const char* contribDoneTitle; ///< recorded @@ -942,7 +944,7 @@ struct UiText { const char* contribPackedFormat; ///< ...done (%s recordings, %s its size; its name under it) const char* contribPackFailedFormat; ///< ...failed (%s why) const char* contribPackNoSpaceFormat; ///< ...not enough space (%s needed) - const char* contribIncompleteFormat; ///< ...recordings it left out, a file missing (%s: their names) + const char* contribIncompleteFormat; ///< ...recordings it left out, not usable (%s: their names) const char* contribDelete; ///< "Delete the recordings" const char* contribClose; ///< the end's "Close" const char* contribDeleteTitle; ///< ...the delete's confirmation diff --git a/panel/src/main.cpp b/panel/src/main.cpp index fd7d601..61ceb7e 100644 --- a/panel/src/main.cpp +++ b/panel/src/main.cpp @@ -386,8 +386,9 @@ void printUsage() { " --fake-password set|unset|unknown The setup's password check (default: set)\n" " --fake-contrib STATE \"Record for squint training\" open on a made-up screen (the eye cameras set up\n" " and idle unless --fake-eyecam says otherwise): consent, deleted, eyewear,\n" - " ready, nospace, running, failed, incomplete, done (just recorded), list\n" - " (\"Recordings\"), packing, packed, delete, deleting (with --adv-page tools: its\n" + " ready, nospace, running, failed, incomplete, truncated, valve (not usable:\n" + " a file missing, a raw file cut short, Valve's values missing), done (just\n" + " recorded), list (\"Recordings\"), packing, packed, delete, deleting (with --adv-page tools: its\n" " row on Debug tools, the sheet closed; none for the row before the first)\n" " --fake-camera both|left|right|uncalibrated|absent|error|off The eye cameras as frameeyeosc reports\n" " them (off: camera_lids off)\n" @@ -459,9 +460,9 @@ bool parsePreviewSpec(const std::string& text, eye_preview::Source& source, bool } /** --fake-contrib's screens (fakeContrib). */ -const std::string kFakeContribStates[] = {"none", "consent", "deleted", "eyewear", "ready", - "nospace", "running", "failed", "incomplete", "done", - "list", "packing", "packed", "delete", "deleting"}; +const std::string kFakeContribStates[] = {"none", "consent", "deleted", "eyewear", "ready", "nospace", + "running", "failed", "incomplete", "truncated", "valve", "done", + "list", "packing", "packed", "delete", "deleting"}; /** --fake-eyecam, taken apart. */ struct FakeEyecam { @@ -1706,6 +1707,12 @@ void fakeContrib(PanelModel& m, const std::string& state, bool eyecamGiven) { if (state == "incomplete") { c.failure = {true, contrib::Why::Incomplete, "cues.csv", "cues.csv", "aborted/rec_2026-10-25_14-20-55"}; } + if (state == "truncated") { + c.failure = {true, contrib::Why::Truncated, "eye_R.raw", "eye_R.raw", "aborted/rec_2026-10-25_14-20-55"}; + } + if (state == "valve") { + c.failure = {true, contrib::Why::Valve, "42%", "42%", "aborted/rec_2026-10-25_14-20-55"}; + } if (state == "packing") { c.progress.job = contrib::Job::Pack; c.progress.running = true; diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index a94cee5..5e4f17a 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -7210,6 +7210,8 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) case contrib::Why::Missing: why = t.contribWhyMissing; break; case contrib::Why::Move: why = formatLong(t.contribWhyMoveFormat, message); break; case contrib::Why::Incomplete: why = formatLong(t.contribWhyIncompleteFormat, message); break; + case contrib::Why::Truncated: why = formatLong(t.contribWhyTruncatedFormat, message); break; + case contrib::Why::Valve: why = formatLong(t.contribWhyValveFormat, message); break; } paragraph(why, 19, kText, false, 3); if (!f.kept.empty()) { diff --git a/panel/tests/contrib_test.cpp b/panel/tests/contrib_test.cpp index 4314c24..13c70e3 100644 --- a/panel/tests/contrib_test.cpp +++ b/panel/tests/contrib_test.cpp @@ -332,7 +332,9 @@ void testInfo() { parseInfo(text + recordingSection(unknown) + consentSection("2026-11-01 10:00:00 +0900", 0, Eyewear::None), info); CHECK(info.recordings.size() == 2 && info.consentVersion == 0 && info.eyewear == Eyewear::None); CHECK(consentNeeded(info)); - if (info.recordings.size() == 2) CHECK(info.recordings[1].offCountStart == -1 && info.recordings[1].eyecamRec.empty()); + if (info.recordings.size() == 2) { + CHECK(info.recordings[1].offCountStart == -1 && info.recordings[1].eyecamRec.empty()); + } // Odd lines are skipped, CRLF read, a recording's name that isn't one dropped, a pack's that isn't one forgotten parseInfo("format=2\r\n[consent]\r\nversion=1\r\neyewear=glasses\r\nnonsense\r\n[recording]\r\nrec=../../etc\r\n" "[recording]\nrec=rec_2026-10-25_14-05-12\nbytes=12x\n[packed]\nfile=/etc/passwd\n[mystery]\nrec=x\n", @@ -620,33 +622,128 @@ void testTarHeader() { CHECK(contrib::tarHeader(long_).size() == 512); } +/** What a made-up recording lacks. */ +enum class Defect { + None, + NoCues, ///< no cues.csv + NoRaw, ///< no eye_L.raw + Truncated, ///< eye_R.raw half a frame short (the disk ran full) + LongerRaw, ///< eye_L.raw a frame longer than frames.csv says + FewValve, ///< Valve's tracking stopped after a third of it + ValveOff, ///< Valve's samples there but not tracking (producer_state 0) + NoFrames, ///< frames.csv with no frame +}; + +/** A made-up frame's size (its meta.txt says so, as eyecam-rec's does). */ +constexpr long long kTestFrameBytes = 1000; + /** - * Make a recording's folder as eyecam-rec leaves one. + * Make a recording's folder as eyecam-rec leaves one: eye_L.raw and eye_R.raw with a frame for each of frames.csv's + * rows per eye (90 a second), valve.csv with a tracking sample every 1/90 s near them, cues.csv, meta.txt. * @param home where * @param name its name - * @param bytes the size of its eye_L.raw (eye_R.raw is a little smaller) + * @param frames frames per eye * @param made when it was made (its modification time; the tests' clock runs ahead of the real one) - * @param without a file it lacks ("" = none) + * @param defect what it lacks * @return its path */ -std::string makeRecording(const std::string& home, const std::string& name, size_t bytes, double made = kNow, - const std::string& without = "") { +std::string makeRecording(const std::string& home, const std::string& name, int frames = 50, double made = kNow, + Defect defect = Defect::None) { const std::string dir = home + "/" + name; ::mkdir(dir.c_str(), 0755); - std::string video(bytes, '\0'); - for (size_t i = 0; i < bytes; ++i) video[i] = static_cast((i * 7 + i / 400) & 0xff); - writeFile(dir + "/eye_L.raw", video); - writeFile(dir + "/eye_R.raw", video.substr(0, bytes - 13)); - for (const auto& [file, text] : {std::pair {"cues.csv", "t,label\n0.0,lead_in\n"}, - {"valve.csv", "t,openness_l\n"}, {"frames.csv", "eye,t_cam\n"}, - {"meta.txt", "protocol=contrib\n"}}) { - if (without != file) writeFile(dir + "/" + file, text); + const int rows = defect == Defect::NoFrames ? 0 : frames; + std::string video(static_cast(rows * kTestFrameBytes), '\0'); + for (size_t i = 0; i < video.size(); ++i) video[i] = static_cast((i * 7 + i / 400) & 0xff); + if (defect != Defect::NoRaw) { + writeFile(dir + "/eye_L.raw", defect == Defect::LongerRaw ? video + video.substr(0, 1000) : video); + } + writeFile(dir + "/eye_R.raw", defect == Defect::Truncated ? video.substr(0, video.size() - 500) : video); + std::string csv = "index,eye,eye_index,slot,t_cam,t_raw,t_copy,valve_seq\n"; + char row[160]; + for (int k = 0; k < rows; ++k) { + for (int eye = 0; eye < 2; ++eye) { + const double t = 100.0 + k / 90.0 + eye * 0.0001; + std::snprintf(row, sizeof(row), "%d,%s,%d,%d,%.9f,%.9f,%.9f,%d\n", 2 * k + eye, eye == 0 ? "L" : "R", k, + eye * 4, t, t + 0.006, t + 0.01, 5000 + k); + csv += row; + } + } + writeFile(dir + "/frames.csv", csv); + std::string valve = "valve_seq,t_seen,sample_time,producer_state,sample_flag,open_l,open_r,gaze_l_x\n"; + for (int j = 0; j < frames; ++j) { + const bool tracking = defect == Defect::ValveOff ? false : defect != Defect::FewValve || j < frames / 3; + std::snprintf(row, sizeof(row), "%d,%.9f,%.9f,%d,1,%s,0.9,0.1\n", 5000 + j, 100.02 + j / 90.0, + 100.004 + j / 90.0, tracking ? 1 : 0, tracking ? "0.85" : "nan"); + valve += row; + } + writeFile(dir + "/valve.csv", valve); + if (defect != Defect::NoCues) { + writeFile(dir + "/cues.csv", "index,t_raw,t_rel,label,seconds,beep\n0,100,0,lead_in,3,1\n"); } + writeFile(dir + "/meta.txt", "protocol=contrib\nframe_bytes=" + std::to_string(kTestFrameBytes) + "\n"); const timespec times[2] = {{static_cast(made), 0}, {static_cast(made), 0}}; ::utimensat(AT_FDCWD, dir.c_str(), times, 0); return dir; } +void testCheck() { + using namespace contrib; + const RemoveDir temp {tempDir()}; + const std::string home = temp.path; + // A whole one + CHECK(checkRecording(makeRecording(home, "rec_2026-10-25_14-00-00")).check == Check::Ok); + // A file missing: which + CheckResult r = checkRecording(makeRecording(home, "rec_2026-10-25_14-00-01", 50, kNow, Defect::NoCues)); + CHECK(r.check == Check::Missing); + SAME(r.detail, "cues.csv"); + r = checkRecording(makeRecording(home, "rec_2026-10-25_14-00-02", 50, kNow, Defect::NoRaw)); + CHECK(r.check == Check::Missing); + SAME(r.detail, "eye_L.raw"); + CHECK(checkRecording(temp.path + "/none").detail == + "eye_L.raw, eye_R.raw, frames.csv, valve.csv, cues.csv, meta.txt"); + // A raw file cut short, or longer than frames.csv says, or no frame at all + r = checkRecording(makeRecording(home, "rec_2026-10-25_14-00-03", 50, kNow, Defect::Truncated)); + CHECK(r.check == Check::Truncated); + SAME(r.detail, "eye_R.raw"); + r = checkRecording(makeRecording(home, "rec_2026-10-25_14-00-04", 50, kNow, Defect::LongerRaw)); + CHECK(r.check == Check::Truncated); + SAME(r.detail, "eye_L.raw"); + r = checkRecording(makeRecording(home, "rec_2026-10-25_14-00-05", 50, kNow, Defect::NoFrames)); + CHECK(r.check == Check::Truncated); + SAME(r.detail, "eye_L.raw, eye_R.raw"); + // meta.txt's frame_bytes counts; without it, eyecam-rec's 160000 + const std::string sized = makeRecording(home, "rec_2026-10-25_14-00-06"); + writeFile(sized + "/meta.txt", "protocol=contrib\n"); + CHECK(checkRecording(sized).check == Check::Truncated); + writeFile(sized + "/meta.txt", "protocol=contrib\nframe_bytes=1000\n"); + CHECK(checkRecording(sized).check == Check::Ok); + // Valve's tracking: stopped after a third, or never tracking (producer_state 0, openness nan) + r = checkRecording(makeRecording(home, "rec_2026-10-25_14-00-07", 90, kNow, Defect::FewValve)); + CHECK(r.check == Check::Valve); + SAME(r.detail, "37%"); // (34 of 90 frames within 0.05 s of a sample of the first 30) + r = checkRecording(makeRecording(home, "rec_2026-10-25_14-00-08", 50, kNow, Defect::ValveOff)); + CHECK(r.check == Check::Valve); + SAME(r.detail, "0%"); + // ...the line at 90 % of the frames: 91 of 100 paired passes, 88 doesn't (a frame pairs with a sample within + // 0.05 s: up to 4 frames past the last one) + const std::string edge = makeRecording(home, "rec_2026-10-25_14-00-09", 100); + const auto keepValve = [&](int keep) { + std::string valve = "valve_seq,t_seen,sample_time,producer_state,sample_flag,open_l,open_r\n"; + char row[128]; + for (int j = 0; j < keep; ++j) { + std::snprintf(row, sizeof(row), "%d,0,%.9f,1,1,0.8,0.8\n", j, 100.0 + j / 90.0); + valve += row; + } + writeFile(edge + "/valve.csv", valve); + return checkRecording(edge).check; + }; + CHECK(keepValve(87) == Check::Ok); + CHECK(keepValve(84) == Check::Valve); + // A valve.csv without its columns pairs nothing + writeFile(edge + "/valve.csv", "a,b\n1,2\n"); + CHECK(checkRecording(edge).check == Check::Valve); +} + void testFiles() { using namespace contrib; const RemoveDir temp {tempDir()}; @@ -664,11 +761,11 @@ void testFiles() { CHECK(info.find("[consent]") < info.find("[aborted]")); CHECK(!appendInfo(temp.path + "/missing", "x", error) && !error.empty()); // The files a recording needs - const std::string full = makeRecording(home, "rec_2026-10-25_14-02-41", 4096); + const std::string full = makeRecording(home, "rec_2026-10-25_14-02-41"); CHECK(missingFiles(full).empty()); - const std::string lacking = makeRecording(home, "rec_2026-10-25_14-03-00", 100, kNow, "cues.csv"); + const std::string lacking = makeRecording(home, "rec_2026-10-25_14-03-00", 50, kNow, Defect::NoCues); CHECK(missingFiles(lacking) == std::vector {"cues.csv"}); - CHECK(missingFiles(temp.path + "/none").size() == 4); + CHECK(missingFiles(temp.path + "/none").size() == 6); // Moved into the folder, and one that didn't finish into aborted/ std::string name; CHECK(takeRecording(home, dir, full, false, name, error)); @@ -684,7 +781,11 @@ void testFiles() { CHECK(!takeRecording(home, dir, home + "/calib_2026-10-25_14-09-00", false, name, error)); CHECK(!takeRecording(home, dir, home + "/rec_2026-10-25_14-59-59", false, name, error)); // (not there) // Sizes: the files in it and its folders - CHECK(folderBytes(dir + "/rec_2026-10-25_14-02-41") == 4096 + 4096 - 13 + 20 + 13 + 10 + 17); + long long sum = 0; + for (const auto& file : std::filesystem::directory_iterator(dir + "/rec_2026-10-25_14-02-41")) { + sum += static_cast(file.file_size()); + } + CHECK(sum > 100'000 && folderBytes(dir + "/rec_2026-10-25_14-02-41") == sum); CHECK(folderBytes(dir) > folderBytes(dir + "/rec_2026-10-25_14-02-41")); CHECK(freeBytes(home) > 0); CHECK(freeBytes(temp.path + "/missing") == -1); @@ -731,17 +832,21 @@ void testPack() { const std::string names[3] = {"rec_2026-10-25_14-02-41", "rec_2026-10-25_14-05-12", "rec_2026-10-25_14-07-30"}; Info info; for (int i = 0; i < 3; ++i) { - takeRecording(home, dir, makeRecording(home, names[i], i == 1 ? 1'500'000 : 300'000, kNow), false, name, error); + takeRecording(home, dir, makeRecording(home, names[i], i == 1 ? 900 : 300, kNow), false, name, error); contrib::Recording r; r.rec = names[i]; r.calib = i == 0 ? names[i] + ".calib_result.json" : "none"; + r.userCalib = i == 0 ? names[i] + ".user_calib.json" : "none"; + r.settings = i == 0 ? names[i] + ".settings.json" : "none"; info.recordings.push_back(r); appendInfo(dir, recordingSection(r), error); } writeFile(dir + "/" + names[0] + ".calib_result.json", "{\"calib\":1}"); - // (the third lost a file since: left out and named) - ::unlink((dir + "/" + names[2] + "/valve.csv").c_str()); - takeRecording(home, dir, makeRecording(home, "rec_2026-10-25_14-04-00", 1000), true, name, error); + writeFile(dir + "/" + names[0] + ".user_calib.json", "{\"user\":1}"); + writeFile(dir + "/" + names[0] + ".settings.json", "{\"lid_veto\":true}"); + // (the third's raw file was cut since: checked again, left out and named) + std::filesystem::resize_file(dir + "/" + names[2] + "/eye_L.raw", 1500); + takeRecording(home, dir, makeRecording(home, "rec_2026-10-25_14-04-00", 10), true, name, error); writeFile(dir + "/contrib_2026-10-24_09-00-00.tar.zst", "an older pack"); // What goes in: the folder, info.txt, the complete recordings (sorted) and the calibration result; not aborted/, // not a pack @@ -749,12 +854,14 @@ void testPack() { std::vector incomplete; CHECK(packEntries(dir, info, entries, incomplete, error)); CHECK(incomplete == std::vector {names[2]}); - CHECK(entries.size() == 2 + 7 + 1 + 7); + CHECK(entries.size() == 2 + 7 + 3 + 7); SAME(entries[0].name, "contrib/"); SAME(entries[1].name, "contrib/info.txt"); SAME(entries[2].name, "contrib/" + names[0] + "/"); SAME(entries[3].name, "contrib/" + names[0] + "/cues.csv"); SAME(entries[9].name, "contrib/" + names[0] + ".calib_result.json"); + SAME(entries[10].name, "contrib/" + names[0] + ".user_calib.json"); + SAME(entries[11].name, "contrib/" + names[0] + ".settings.json"); for (const Entry& entry : entries) { CHECK(entry.name.find("aborted") == std::string::npos && entry.name.find(".tar") == std::string::npos); CHECK(entry.name.find(names[2]) == std::string::npos); @@ -762,9 +869,10 @@ void testPack() { // Packed as tar: unpacks to the same files Worker worker; const std::string tarName = packName(static_cast(kNow), false); - CHECK(worker.pack(dir, entries, tarName, 2, false)); + CHECK(worker.pack(dir, info, tarName, false)); Progress ended = waitFor(worker); CHECK(ended.finished && ended.ok && ended.count == 2); + CHECK(ended.incomplete == std::vector {names[2]}); SAME(ended.output, tarName); CHECK(ended.done == ended.total && ended.total > 0); const std::string tar = dir + "/" + tarName; @@ -781,11 +889,13 @@ void testPack() { } SAME(readFile(out + "/contrib/info.txt"), readFile(dir + "/info.txt")); SAME(readFile(out + "/contrib/" + names[0] + ".calib_result.json"), "{\"calib\":1}"); + SAME(readFile(out + "/contrib/" + names[0] + ".user_calib.json"), "{\"user\":1}"); + SAME(readFile(out + "/contrib/" + names[0] + ".settings.json"), "{\"lid_veto\":true}"); CHECK(!there(out + "/contrib/aborted") && !there(out + "/contrib/" + names[2])); // ...and through zstd, when it is there if (zstdAvailable()) { const std::string zstName = packName(static_cast(kNow) + 1, true); - CHECK(worker.pack(dir, entries, zstName, 2, true)); + CHECK(worker.pack(dir, info, zstName, true)); ended = waitFor(worker); CHECK(ended.finished && ended.ok); const std::string out2 = temp.path + "/out2"; @@ -795,6 +905,13 @@ void testPack() { } else { std::printf("contrib-test: no zstd here, its pack not tried\n"); } + // Nothing usable: no pack, and why + Info none; + none.recordings.push_back(info.recordings[2]); + CHECK(worker.pack(dir, none, packName(static_cast(kNow) + 2, false), false)); + ended = waitFor(worker); + CHECK(ended.finished && !ended.ok && ended.count == 0 && !ended.error.empty()); + CHECK(!there(dir + "/" + packName(static_cast(kNow) + 2, false))); // Deleting: only home/contrib itself CHECK(!worker.remove(home, dir + "/" + names[0])); CHECK(!worker.remove(temp.path, dir)); @@ -812,11 +929,11 @@ void testPack() { * @param now the tests' clock (moved on) * @param eyecam eyecam-rec as it is before (its count and run) * @param name the recording's folder - * @param without a file it lacks + * @param defect what it lacks * @param aborted it ended early */ void record(contrib::Session& session, contrib::View& view, const std::string& home, double& now, eyecam::View eyecam, - const std::string& name, const std::string& without = "", bool aborted = false) { + const std::string& name, Defect defect = Defect::None, bool aborted = false) { std::string log; contrib::Snapshot snapshot; snapshot.appVersion = "0.8.2-test"; @@ -834,7 +951,7 @@ void record(contrib::Session& session, contrib::View& view, const std::string& h running.status = status(State::Recording, contrib::kProtocol, now); session.follow(view, running, now, now, log); CHECK(view.running); - const std::string rec = makeRecording(home, name, 600, now, without); + const std::string rec = makeRecording(home, name, 60, now, defect); now += aborted ? 20 : 96; eyecam::View after = eyecam; after.status.mtime = now; @@ -852,10 +969,14 @@ void testSession() { ::mkdir(home.c_str(), 0755); ::mkdir((home + "/calib_2026-10-25_09-00-00").c_str(), 0755); writeFile(home + "/calib_2026-10-25_09-00-00/calib_result.json", "{\"ok\":true}"); + const std::string config = temp.path + "/config"; + ::mkdir(config.c_str(), 0755); + writeFile(config + "/calib.json", "{\"user\":{\"squint\":0.4}}"); + writeFile(config + "/settings.json", "{\"lid_veto\":true}"); View view; Session session; std::string log; - session.load(view, home); + session.load(view, home, config); CHECK(view.loaded && !view.exists && view.freeBytes > 0); CHECK(screen(view, Page::Record, false) == Screen::Consent); double now = static_cast(std::time(nullptr)); @@ -884,9 +1005,13 @@ void testSession() { CHECK(r.offCountStart == 2 && r.offCountEnd == 2); CHECK(std::fabs(r.fps - 89.9) < 1e-6); SAME(r.calib, "rec_2026-10-25_14-02-41.calib_result.json"); + SAME(r.userCalib, "rec_2026-10-25_14-02-41.user_calib.json"); + SAME(r.settings, "rec_2026-10-25_14-02-41.settings.json"); CHECK(r.bytes > 1000); } SAME(readFile(view.dir + "/rec_2026-10-25_14-02-41.calib_result.json"), "{\"ok\":true}"); + SAME(readFile(view.dir + "/rec_2026-10-25_14-02-41.user_calib.json"), "{\"user\":{\"squint\":0.4}}"); + SAME(readFile(view.dir + "/rec_2026-10-25_14-02-41.settings.json"), "{\"lid_veto\":true}"); CHECK(there(view.dir + "/rec_2026-10-25_14-02-41/cues.csv") && !there(home + "/rec_2026-10-25_14-02-41")); // The second, 10 minutes later with the headset taken off between (the count went up) now += 600; @@ -905,16 +1030,31 @@ void testSession() { SAME(view.info.recordings[2].sincePrevious, "0.0"); SAME(view.info.recordings[3].headsetOff, "unknown"); } - // One lacking a file: into aborted/, the screen says which, to record again - record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-18-00", "frames.csv"); + // One lacking a file, one with a raw file cut short, one without Valve's values: each into aborted/, the screen + // says why, to record again + record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-18-00", Defect::NoCues); CHECK(view.info.recordings.size() == 4 && view.failure.shown && view.failure.why == Why::Incomplete); - SAME(view.failure.message, "frames.csv"); + SAME(view.failure.message, "cues.csv"); SAME(view.failure.kept, "aborted/rec_2026-10-25_14-18-00"); CHECK(screen(view, Page::Record, false) == Screen::Failed); CHECK(there(view.dir + "/aborted/rec_2026-10-25_14-18-00")); + record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-18-30", Defect::Truncated); + CHECK(view.info.recordings.size() == 4 && view.failure.why == Why::Truncated); + SAME(view.failure.message, "eye_R.raw"); + CHECK(there(view.dir + "/aborted/rec_2026-10-25_14-18-30")); + record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-18-45", Defect::FewValve); + CHECK(view.info.recordings.size() == 4 && view.failure.why == Why::Valve); + SAME(view.failure.message, "40%"); + CHECK(there(view.dir + "/aborted/rec_2026-10-25_14-18-45")); + CHECK(view.info.aborted.size() == 3); + if (view.info.aborted.size() == 3) { + SAME(view.info.aborted[0], "aborted/rec_2026-10-25_14-18-00 missing cues.csv"); + SAME(view.info.aborted[1], "aborted/rec_2026-10-25_14-18-30 cut short: eye_R.raw"); + SAME(view.info.aborted[2], "aborted/rec_2026-10-25_14-18-45 Valve's samples for only 40% of the frames"); + } // One stopped part way: into aborted/ too - record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-19-00", "", true); - CHECK(view.failure.why == Why::Stopped && view.info.aborted.size() == 2); + record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-19-00", Defect::None, true); + CHECK(view.failure.why == Why::Stopped && view.info.aborted.size() == 4); // Refused at once (a reply with an error): failed with its reason { const eyecam::View eyecam = eyecamReady(now); @@ -931,7 +1071,7 @@ void testSession() { View again; Session restarted; restarted.load(again, home); - CHECK(again.exists && again.info.recordings.size() == 4 && again.info.aborted.size() == 2); + CHECK(again.exists && again.info.recordings.size() == 4 && again.info.aborted.size() == 4); CHECK(again.info.eyewear == Eyewear::Glasses && !consentNeeded(again.info)); } // Packed (tar); another recording, packed again: the first pack goes, the recordings stay @@ -975,6 +1115,7 @@ int main() { testRunWatch(); testScreen(); testTarHeader(); + testCheck(); testFiles(); testPack(); testSession(); From e4c9258e2f4f53f27777e9a9b1776c4be930c6fb Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 12:58:31 +0900 Subject: [PATCH 40/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E3=80=8C?= =?UTF-8?q?=E7=B4=B0=E3=82=81=E3=81=AE=E5=AD=A6=E7=BF=92=E7=94=A8=E3=81=AB?= =?UTF-8?q?=E6=92=AE=E3=82=8B=E3=80=8D=E3=81=AE=E3=82=B7=E3=83=BC=E3=83=88?= =?UTF-8?q?=E3=82=92=E9=96=8B=E3=81=8F=E3=81=9F=E3=81=B3=E3=81=AB=E3=83=95?= =?UTF-8?q?=E3=82=A9=E3=83=AB=E3=83=80=E3=82=92=E8=AA=AD=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=80=81=E3=81=BE=E3=81=A8=E3=82=81=E3=82=8B=E5=89=8D?= =?UTF-8?q?=E3=81=AB=E7=A9=BA=E3=81=8D=E3=82=92=E3=81=9D=E3=81=AE=E5=A0=B4?= =?UTF-8?q?=E3=81=A7=E7=A2=BA=E3=81=8B=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手で消したり変えたりした ~/eyecam/contrib/ も、シートを開けばそのとおりに出る(追っている録画とまとめ・消すの仕事はそのまま)。 まとめる前は空きを読み直して packNeedBytes と比べ、足りなければ始めずにわけを出す(前のまとめファイルは今の空きにもう入っていて、 新しいのができてから消すので、必要量には足さない)。テストとドキュメントも合わせる。 Co-Authored-By: Claude Opus 5.5 --- panel/README.md | 4 ++-- panel/src/contrib.cpp | 23 +++++++++++++++++++++++ panel/src/contrib.h | 10 +++++++++- panel/src/main.cpp | 13 +++++++++---- panel/tests/contrib_test.cpp | 16 ++++++++++++++++ 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/panel/README.md b/panel/README.md index fea5a00..3cfa22e 100644 --- a/panel/README.md +++ b/panel/README.md @@ -273,10 +273,10 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - **録れなかった**: 赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった・ファイルが足りなかった〔そのファイル名〕・ファイルが途中で切れていた〔「空き容量を確かめて」〕・Valve の目のトラッキングの値が足りなかった〔フレームの何 % にあったか。「SteamVR の目のトラッキングが動いてるか確かめて」〕。どれも「もう一度撮ってね」)、録画のフォルダがあれば灰色で「この録画は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度撮る] - **終わり**(撮れたあと、または[録ったもの]): 撮れたあとは緑のチェックと「撮れたよ。ありがとう!」、[録ったもの]なら「録ったもの」。「これまで 4 本・合計 10.9 GB」、灰色で「場所: ~/eyecam/contrib/」、撮れたあとは「別の日や、かぶり直したあとにもう一度押してくれると、もっと助かるよ」、送り方の注意の枠。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.7 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(4 本・6.3 GB)」とその下にファイル名(それより本数が増えるまでは押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。その下に灰色で「まとめたファイルは .tar.zst(7-Zip などで開けるよ)」(zstd が無くて .tar になるときは出さない)。まとめるときに学習に使えなかった録画は赤で「rec_… は学習に使えない形だったので入れなかったよ。もう一度撮ってね」。下の左に[録画を消す]、右に[閉じる] - **消す確認**: 赤い枠のカードで「撮った録画を全部消す?」「~/eyecam/contrib/ を中身ごと(10.9 GB)消します。元に戻せないよ。開発者に送ったあとで消してね。」、[やめる](アクセント)と[消す]。消している間は「消しています…」、消したら同意の画面(同意も `info.txt` と一緒に消える) -- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib/info.txt` を読む。[撮影をはじめる]は `ctl.sock` に `start contrib` を送り、そのときの値を覚える(下の `info.txt`)。終わりは `status.json` だけで見る(`contrib::RunWatch`): `contrib` の `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだもので学習に使えるものは `rename` でフォルダへ移し、撮り始めたときに読んだ `~/eyecam/calib_日時/calib_result.json` のいちばん新しいもの・`~/.config/eyecam/calib.json`(ユーザー校正)・`~/.config/eyecam/settings.json` の写しを、`rec_日時.calib_result.json`・`rec_日時.user_calib.json`・`rec_日時.settings.json` として隣に置く(無いものは `info.txt` に `none`)。学習に使える録画かは `contrib::checkRecording` が CSV の行と raw の大きさだけで見る(映像は読まない。3 分の録画でも数十 ms): `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` の 6 つがそろっている、目ごとの raw の大きさが `frames.csv` のその目の行数 × 1 フレーム(`meta.txt` の `frame_bytes`、無ければ 160000 B)とちょうど同じで 1 枚以上ある(ディスクがいっぱいで途中で切れた raw をはじく)、`frames.csv` のフレームの 9 割以上に、`t_cam` の前後 0.05 秒以内の使える Valve のサンプル(`valve.csv` の `producer_state` が 1 で `open_l`・`open_r` が数。`sample_time` は `t_cam` と同じ時計)がある(ラベルは Valve の開き具合からも作るので)。Frame の自分の録画 13 本はどれも通った。使えない・止まった・失敗したものは `aborted/` へ(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない)。パネルが録画の途中で起動し直したときは追わない(その録画は `~/eyecam/rec_日時/` に残る) +- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib/info.txt` を読み、シートを開くたびに読み直す(手で消したり変えたりしたフォルダもそのとおりに出る。追っている録画とまとめ・消すの仕事はそのまま)。[撮影をはじめる]は `ctl.sock` に `start contrib` を送り、そのときの値を覚える(下の `info.txt`)。終わりは `status.json` だけで見る(`contrib::RunWatch`): `contrib` の `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだもので学習に使えるものは `rename` でフォルダへ移し、撮り始めたときに読んだ `~/eyecam/calib_日時/calib_result.json` のいちばん新しいもの・`~/.config/eyecam/calib.json`(ユーザー校正)・`~/.config/eyecam/settings.json` の写しを、`rec_日時.calib_result.json`・`rec_日時.user_calib.json`・`rec_日時.settings.json` として隣に置く(無いものは `info.txt` に `none`)。学習に使える録画かは `contrib::checkRecording` が CSV の行と raw の大きさだけで見る(映像は読まない。3 分の録画でも数十 ms): `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` の 6 つがそろっている、目ごとの raw の大きさが `frames.csv` のその目の行数 × 1 フレーム(`meta.txt` の `frame_bytes`、無ければ 160000 B)とちょうど同じで 1 枚以上ある(ディスクがいっぱいで途中で切れた raw をはじく)、`frames.csv` のフレームの 9 割以上に、`t_cam` の前後 0.05 秒以内の使える Valve のサンプル(`valve.csv` の `producer_state` が 1 で `open_l`・`open_r` が数。`sample_time` は `t_cam` と同じ時計)がある(ラベルは Valve の開き具合からも作るので)。Frame の自分の録画 13 本はどれも通った。使えない・止まった・失敗したものは `aborted/` へ(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない)。パネルが録画の途中で起動し直したときは追わない(その録画は `~/eyecam/rec_日時/` に残る) - **info.txt**(`~/eyecam/contrib/info.txt`): 初めに何のファイルかのコメントと `format=2`、そのあと出来事ごとに節を書き足す。`[consent]`(`time`・`version`・`eyewear`: `none` / `glasses` / `inserts` / `no_answer`。最後の節が効く)、`[recording]`(`rec`・`protocol`・`start`・`start_unix`・`end_unix`・`bytes`・`app_version`・`eyecam_rec_sha256`〔入っている eyecam-rec の SHA-256 の先頭 8 桁。git のハッシュは持っていない〕・`lid_veto`(`on` / `off` / `unknown`)・`widen`・`widen_sensitivity`・`camera_lids`・`eyewear`・`camera_fps`・`minutes_since_previous`(前の録画が終わってから何分。1 本目は `none`)・`headset_off`(そのあいだに HMD を外したか: `yes` / `no` / `unknown` / 1 本目は `none`)・`headset_off_count_start` / `_end`・`eyecam_started_unix`・`calib_result`・`user_calib`・`settings`)、`[aborted]`(`rec`・`why`: `stopped` / `failed` / `missing cues.csv` / `cut short: eye_R.raw` / `Valve's samples for only 40% of the frames`)、`[packed]`(`file`・`recordings`・`bytes`)。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) - **外したかの見分け**: eyecam-rec の `status.json` の `headset_off_count`(起動してから、目の映像が 10 秒以上止まったか近接センサーが 10 秒以上「かぶっていない」になって、そのあとまたかぶった回数。サスペンドも数える時計で測る。tools/eyecam の README)と `started_unix` で決める。前の録画の終わりと今回の始まりで eyecam-rec が同じ起動(`started_unix` が同じ)なら、数が増えていれば `yes`、同じなら `no`。eyecam-rec が起動し直していた・古い eyecam-rec で値が無いときは `unknown`。続けて 2 回押しただけの録画を別のかぶりとして数えないため -- **まとめる**(`contrib::Worker`、別のスレッド、nice 10): 別のスレッドの中で録画を同じ条件(`checkRecording`)で確かめ直してから、`info.txt`、使える録画と隣の写しだけを ustar の tar(中のフォルダ名は `contrib/`。8 GiB 以上の大きさは GNU の base-256)にして、zstd があれば `zstd -6 -T2` に通し、`contrib_日時.tar.zst`(無ければ `.tar`)にする。Frame の目の映像 2 GB で測ると、zstd -3 は 0.62 倍・7.7 秒、-6 は 0.58 倍・20 秒、-9 は 0.56 倍・43 秒、-12 は 0.55 倍・90 秒、zip -6 は 0.60 倍・166 秒、gzip -1 は 0.62 倍・19 秒だったので、-6 にした(SteamOS には zstd も tar も zip も入っている。7z は無い)。`.part` に書いて fsync してから名前を変え、失敗したら `.part` を消す。始める前に空きを確かめる(録画の 0.7 倍〔zstd なし: 1 倍〕と 1 GiB)。できたら `[packed]` を書き足し、前にまとめたファイル(本数の少ないもの)は消す。元の録画には触らない +- **まとめる**(`contrib::Worker`、別のスレッド、nice 10): 別のスレッドの中で録画を同じ条件(`checkRecording`)で確かめ直してから、`info.txt`、使える録画と隣の写しだけを ustar の tar(中のフォルダ名は `contrib/`。8 GiB 以上の大きさは GNU の base-256)にして、zstd があれば `zstd -6 -T2` に通し、`contrib_日時.tar.zst`(無ければ `.tar`)にする。Frame の目の映像 2 GB で測ると、zstd -3 は 0.62 倍・7.7 秒、-6 は 0.58 倍・20 秒、-9 は 0.56 倍・43 秒、-12 は 0.55 倍・90 秒、zip -6 は 0.60 倍・166 秒、gzip -1 は 0.62 倍・19 秒だったので、-6 にした(SteamOS には zstd も tar も zip も入っている。7z は無い)。`.part` に書いて fsync してから名前を変え、失敗したら `.part` を消す。始める前に、その場で読み直した空きで確かめる(録画の 0.7 倍〔zstd なし: 1 倍〕と 1 GiB。前にまとめたファイルは今の空きにもう入っていない〔新しいのができてから消す〕ので、足さない)。できたら `[packed]` を書き足し、前にまとめたファイル(本数の少ないもの)は消す。元の録画には触らない - **消す**: 確認のあと、`~/eyecam/contrib` だけを(中のリンクはたどらずに)丸ごと消す ## ほかのアプリから動かす(--action) diff --git a/panel/src/contrib.cpp b/panel/src/contrib.cpp index 2664f08..97c6e33 100644 --- a/panel/src/contrib.cpp +++ b/panel/src/contrib.cpp @@ -1034,6 +1034,19 @@ void Session::load(View& view, const std::string& home, const std::string& confi view.totalBytes = folderBytes(view.dir); } +void Session::refresh(View& view) { + if (view.home.empty()) return; + view.exists = isRealDir(view.dir); + view.freeBytes = freeBytes(view.home); + view.info = Info(); + view.totalBytes = 0; + if (!view.exists) return; + std::string text; + file_io::read(view.dir + "/" + kInfoFile, text, kMaxInfoBytes); + parseInfo(text, view.info); + view.totalBytes = folderBytes(view.dir); +} + bool Session::consent(View& view, Eyewear eyewear, std::time_t now) { if (eyewear == Eyewear::Unasked || view.home.empty()) return false; for (const std::string& dir : {view.home, view.dir}) { @@ -1163,6 +1176,16 @@ bool Session::follow(View& view, const eyecam::View& eyecam, double nowUnix, dou bool Session::pack(View& view, std::time_t now) { if (!view.exists || view.running || view.info.recordings.empty()) return false; + // (the free space read now: the panel's button follows the one read every 2 s) + view.freeBytes = freeBytes(view.home); + const long long need = packNeedBytes(view.info, view.compress); + if (view.freeBytes >= 0 && view.freeBytes < need) { + view.progress = Progress(); + view.progress.job = Job::Pack; + view.progress.finished = true; + view.progress.error = "not enough space (" + sizeText(need) + " needed, " + sizeText(view.freeBytes) + " free)"; + return false; + } const bool ok = worker_.pack(view.dir, view.info, packName(now, view.compress), view.compress); view.progress = worker_.progress(); return ok; diff --git a/panel/src/contrib.h b/panel/src/contrib.h index 7313f5e..e3388ba 100644 --- a/panel/src/contrib.h +++ b/panel/src/contrib.h @@ -597,6 +597,13 @@ class Session { */ void load(View& view, const std::string& home, const std::string& configDir = defaultConfigDir()); + /** + * Read the folder and its info.txt again (the sheet opened: it may have been changed or deleted by hand), keeping + * the recording followed and the job: only what is on the disk (exists, info, its size, the free space). + * @param view the contribution + */ + void refresh(View& view); + /** * The consent and the answer about glasses: the folder (made if needed), and their section in info.txt. * @param view the contribution @@ -645,7 +652,8 @@ class Session { bool follow(View& view, const eyecam::View& eyecam, double nowUnix, double bootNow, std::string& log); /** - * Pack the folder into one file (on the worker). + * Pack the folder into one file (on the worker), when the space it needs is free (packNeedBytes; an earlier pack + * is already taken from the free space, and goes only once the new one is complete). * @param view the contribution * @param now the time (the file's name) * @return false if it can't start (view.progress says why) diff --git a/panel/src/main.cpp b/panel/src/main.cpp index 61ceb7e..a8cb0a1 100644 --- a/panel/src/main.cpp +++ b/panel/src/main.cpp @@ -3302,10 +3302,15 @@ void applyHit(const PanelHit& hit, PanelModel& model, EyePanel& panel, Autostart case PanelAction::ContribDelete: case PanelAction::ContribDeleteCancel: return; // handled inside the panel case PanelAction::ContribOpen: - // Read from the disk the first time (the loop reads it at its start and follows it from then on); each - // opening starts afresh: the last recording's end and failure are not shown again - if (contribSession != nullptr && !model.contrib.loaded) { - contribSession->load(model.contrib, contrib::defaultHome()); + // Read from the disk; each opening starts afresh: the last recording's end and failure are not shown + // again + // (again each time: the folder may have been changed or deleted by hand meanwhile) + if (contribSession != nullptr) { + if (model.contrib.loaded) { + contribSession->refresh(model.contrib); + } else { + contribSession->load(model.contrib, contrib::defaultHome()); + } } model.contrib.justDone = false; model.contrib.failure = contrib::Failure(); diff --git a/panel/tests/contrib_test.cpp b/panel/tests/contrib_test.cpp index 13c70e3..bb47ee4 100644 --- a/panel/tests/contrib_test.cpp +++ b/panel/tests/contrib_test.cpp @@ -1093,6 +1093,22 @@ void testSession() { CHECK(view.info.packed != first && view.info.packedCount == 5); CHECK(!there(view.dir + "/" + first) && there(view.dir + "/" + view.info.packed)); CHECK(there(view.dir + "/rec_2026-10-25_14-02-41/eye_L.raw")); + // Not enough space to pack: refused, and why (the free space is read again for it) + { + View big = view; + big.info.recordings.back().bytes = 1LL << 50; + CHECK(!session.pack(big, static_cast(now) + 120)); + CHECK(big.progress.finished && !big.progress.ok && big.progress.error.find("not enough space") == 0); + } + // Changed by hand while the panel runs: read again when the sheet opens (the folder renamed away, then back) + const std::string aside = temp.path + "/aside"; + CHECK(std::rename(view.dir.c_str(), aside.c_str()) == 0); + session.refresh(view); + CHECK(!view.exists && view.info.recordings.empty() && consentNeeded(view.info)); + CHECK(screen(view, Page::Record, false) == Screen::Consent); + CHECK(std::rename(aside.c_str(), view.dir.c_str()) == 0); + session.refresh(view); + CHECK(view.exists && view.info.recordings.size() == 5 && !consentNeeded(view.info) && view.totalBytes > 0); // Deleted: the consent again, saying so CHECK(session.remove(view)); for (int i = 0; i < 3000 && view.exists; ++i) { From e3cd0665c040ce8572a00d581df81d68a5cae75b Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 13:09:34 +0900 Subject: [PATCH 41/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E3=80=8C?= =?UTF-8?q?=E7=B4=B0=E3=82=81=E3=81=AE=E5=AD=A6=E7=BF=92=E7=94=A8=E3=81=AB?= =?UTF-8?q?=E6=92=AE=E3=82=8B=E3=80=8D=E3=81=AF=E6=9C=AC=E4=BD=93=E3=81=AB?= =?UTF-8?q?=E7=9B=AE=E3=81=AE=E3=83=87=E3=83=BC=E3=82=BF=E3=81=8C=E6=9D=A5?= =?UTF-8?q?=E3=81=A6=E3=81=84=E3=82=8B=E3=81=A8=E3=81=8D=E3=81=A0=E3=81=91?= =?UTF-8?q?=E6=92=AE=E3=82=8C=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=97?= =?UTF-8?q?=E3=80=81=E7=89=87=E7=9B=AE=E3=81=AE=E6=98=A0=E5=83=8F=E3=81=8C?= =?UTF-8?q?=E7=84=A1=E3=81=84=E9=8C=B2=E7=94=BB=E3=81=AE=E3=82=8F=E3=81=91?= =?UTF-8?q?=E3=82=92=E5=88=86=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Valve のサンプルは本体が頼んでいるあいだしか来ないので、本体が止まっているか目のデータが来ていないときは[撮影をはじめる]を押せなくして、 そのわけを出す(撮っても 93 秒あとに「Valve の値が 0 %」で aborted になるため)。frames.csv に片目でも 1 枚もないときは「途中で切れた」 ではなく「(右目の)映像がなかったよ。目のカメラの準備を確かめて」と出す。シートを開いて読み直すとき、前の書き込みの失敗の文は消す。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 3 ++- CHANGELOG.md | 3 ++- panel/README.md | 10 ++++---- panel/src/contrib.cpp | 20 +++++++++++---- panel/src/contrib.h | 24 +++++++++++------- panel/src/i18n.cpp | 6 +++++ panel/src/i18n.h | 3 +++ panel/src/main.cpp | 15 ++++++++--- panel/src/panel.cpp | 20 +++++++++++---- panel/tests/contrib_test.cpp | 48 +++++++++++++++++++++++++----------- panel/tests/panel_test.cpp | 6 +++++ 11 files changed, 114 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index d15b271..68de0dc 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -5,7 +5,8 @@ パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。 - 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります -- 録画は `~/eyecam/contrib/` にたまります。学習に使えるものだけを移します: `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろい、目ごとの raw がちょうど `frames.csv` のその目の枚数ぶんあり(ディスクがいっぱいで途中で切れたものをはじく)、フレームの 9 割以上に Valve の目のトラッキングの値がある。CSV とファイルの大きさだけで確かめ、まとめるときにも確かめ直します。使えない・途中で止まったものはわけと一緒に `aborted/` に入れて、撮り直してもらいます。隣には、いちばん新しい校正結果・ユーザー校正(`calib.json`)・eyecam の設定の写しを置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません +- 録画は `~/eyecam/contrib/` にたまります。学習に使えるものだけを移します: `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろい、両目のフレームがあり、目ごとの raw がちょうど `frames.csv` のその目の枚数ぶんあり(ディスクがいっぱいで途中で切れたものをはじく)、フレームの 9 割以上に Valve の目のトラッキングの値がある。CSV とファイルの大きさだけで確かめ、まとめるときにも確かめ直します。使えない・途中で止まったものはわけと一緒に `aborted/` に入れて、撮り直してもらいます。隣には、いちばん新しい校正結果・ユーザー校正(`calib.json`)・eyecam の設定の写しを置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません +- [撮影をはじめる]は、本体が動いていて目のデータが来ているときだけ押せます(ラベルに要る Valve の値は、本体が頼んでいるあいだしか来ないため)。押せないときはそう出します - 終わりの画面([録ったもの]からも)に、本数と合計の大きさ、フォルダ、送り方、[1 つのファイルにまとめる](`zstd -6` で `.tar.zst`。Frame の目の映像で 0.58 倍だった。7-Zip などで開ける。zstd が無ければ `.tar`。空き容量を確かめてから、低い優先度で裏でまとめる。元の録画は残す)、[録画を消す](確認つき)を出します - eyecam: 新しい `protocol_contrib.txt`。`look_left`(長 2 回、高→低)と `look_right`(長 2 回、低→高)に専用のビープ(今までは細めと同じ音だった)。status.json に `headset_off_count`(eyecam-rec が起動してから、目の映像が止まるか近接センサーで 10 秒以上外していて、またかぶった回数。サスペンドも数える時計で測る)と `started_unix` を足しました - `frameeyeosc-panel --dump-png … --fake-contrib 状態` で、それぞれの画面を描けます diff --git a/CHANGELOG.md b/CHANGELOG.md index 235eef9..5ed4ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ New in the panel: "Record for squint training" (Advanced › Debug tools, once t 日本語: パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。 - "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (privately, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). -- The recordings gather in `~/eyecam/contrib/`, each moved there once it can be used for training: `eye_L.raw`, `eye_R.raw`, `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` there, each raw file exactly as many frames as `frames.csv` lists for that eye (one cut short when the disk ran full is caught), and Valve's tracking values near at least 90 % of the frames; checked from the CSV files and file sizes alone, again when packing. One that can't be used, or stopped early, goes to `aborted/` with why, to be recorded again. Beside each go copies of the newest calibration result, the user calibration (`calib.json`) and eyecam's settings then. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. +- The recordings gather in `~/eyecam/contrib/`, each moved there once it can be used for training: `eye_L.raw`, `eye_R.raw`, `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` there, frames of both eyes, each raw file exactly as many frames as `frames.csv` lists for that eye (one cut short when the disk ran full is caught), and Valve's tracking values near at least 90 % of the frames; checked from the CSV files and file sizes alone, again when packing. One that can't be used, or stopped early, goes to `aborted/` with why, to be recorded again. Beside each go copies of the newest calibration result, the user calibration (`calib.json`) and eyecam's settings then. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. +- "Start recording" waits for frameeyeosc to run with eye data coming in (Valve's values, which the labels need, only come while it asks for them), and says so. - The end's screen (also from "Recordings") shows how many there are and their size, the folder, how to send it, "Make one file" (a `.tar.zst` through `zstd -6`, measured on the Frame's eye video at 0.58 of the size, which 7-Zip and the like open; a `.tar` without zstd; packed in the background at a low priority after checking the free space; the recordings stay) and "Delete the recordings" (after a confirmation). - eyecam: new `protocol_contrib.txt`; new beeps for `look_left` (2 long, high then low) and `look_right` (2 long, low then high), which had the squint's beep; status.json's new `headset_off_count` (the times the headset was taken off for 10 s or more and worn again since eyecam-rec started, by the eye video stopping or the proximity sensor, on a clock that counts suspend) and `started_unix`. - `frameeyeosc-panel --dump-png … --fake-contrib STATE` draws each of its screens. diff --git a/panel/README.md b/panel/README.md index 3cfa22e..1f18523 100644 --- a/panel/README.md +++ b/panel/README.md @@ -243,7 +243,7 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - `--report-make 種類 始め 終わり 結果 理由`(確かめるためのもの): その時間のジャーナルと Valve のログから、パネルが走り終わったあとに書くのと同じ記録を `--reports-dir` に書き、フォルダを出す(例: `--report-make fit "2026-10-06 20:45:09" "2026-10-06 20:45:17" failed "…"`。条件と診断コードは空) - 更新履歴は `--history` で開き、`--history-open 版` でその版の行を開き、`--history-scroll px` でスクロールして描く(どちらも `--history` を含む。スクロールは中身の範囲に収める)。変更履歴は上の場所から探す。`--changelog-dir DIR` でそのフォルダだけを見る(無いフォルダなら「見つかりません」の見た目)。`--fake` とも一緒に使える - `--click X,Y`(何回でも)は、描く前にその座標を押したことにする。当たり判定と設定ファイルの書き込みをヘッドセットなしで確かめる用(`--fake` とは一緒に使えない。`--config` の設定ファイルを本当に書き換えるので、試すときは別の場所を指定する) -- 「細めの学習用に撮る」(下)は `--fake-contrib 状態` で、作り物のフォルダ(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意)・`deleted`(消したあとの同意)・`eyewear`(眼鏡・インサートの質問)・`ready`(撮る前。これまで 3 本)・`nospace`(空きが足りない)・`running`(録画中: 2 回目の細め)・`failed`(途中で止まった)・`incomplete`(ファイルが足りなかった)・`truncated`(raw が途中で切れていた)・`valve`(Valve の値が足りなかった)・`done`(撮れた: 4 本)・`list`([録ったもの]。1 本はファイルが足りずにまとめから外れた)・`packing`(まとめている 45 %)・`packed`(まとめた)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」の行を描く(`none` で 1 本目の前)。何も読み書きしない +- 「細めの学習用に撮る」(下)は `--fake-contrib 状態` で、作り物のフォルダ(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意)・`deleted`(消したあとの同意)・`eyewear`(眼鏡・インサートの質問)・`ready`(撮る前。これまで 3 本)・`noeyedata`(本体に目のデータが来ていない)・`nospace`(空きが足りない)・`running`(録画中: 2 回目の細め)・`failed`(途中で止まった)・`incomplete`(ファイルが足りなかった)・`novideo`(右目の映像がなかった)・`truncated`(raw が途中で切れていた)・`valve`(Valve の値が足りなかった)・`done`(撮れた: 4 本)・`list`([録ったもの]。1 本はファイルが足りずにまとめから外れた)・`packing`(まとめている 45 %)・`packed`(まとめた)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」の行を描く(`none` で 1 本目の前)。何も読み書きしない - 目の撮影タブ(下)は `--fake-eyecam waiting|idle|confirm|searching|recording:ラベル|error` と `--tab eyecam` で描く(`confirm` は idle で光の注意を開いたところ。`recording:ラベル:unlocked` でカメラが目を見失ったとき=「HMD をかぶってください」、`recording:ラベル:nolight` で光なしのプロトコル=「光なし」。`:pupil=0.97,0.04` で目ごとの瞳の見え具合(`null` も可。無ければ古い eyecam-rec)、`:failed=L|R|LR|mixed`(`:prov`)で片目だけ失敗した校正や両目の失敗のメッセージ。`:page=user:lookdown=L|R|LR` で、下を見たときの補正をその目(LR は両目)だけ標準の値にして通ったユーザー校正のカード、`:lookprev=L|R|LR` で前の値のままにして通ったカード(`lookdown=R:lookprev=L` で目ごとに違うとき)。`:search=not_worn|no_video|one_eye` で映像が見つからない理由(`status.json` の `search`。`not_worn` は近接センサー 12)。ラベルは lead_in・normal・widen・close・squint・look_up・look_down・bright・dark・end。recording は eyecam の `protocol_widen.txt`(17 ステップ・83 秒。`nolight` なら `protocol_widen_nolight.txt` の 14 ステップ)でそのラベルが最初に出るステップになり、その手順をステップのチップにも使う(ファイルは読まず、同じ手順を main.cpp に持っている)。error は失敗した返事も出す)。`--eyecam-fill-png PATH`(`--eyecam-fill bright|dark`、`--language`)で全面の光の画像。`--eyecam-dir DIR` で eyecam-rec のフォルダ(`status.json` と `ctl.sock`)を別の場所にする。`--fake` なしの `--dump-png` はそこを読み、`--click` で[撮影開始]→[光ありで始める]/[光なしで始める]や[中止]を押すとそのソケットに本当に送って返事を待つ(試すときは一時フォルダに偽の `status.json` とソケットを置く。本物の `/run/user/1000/eyecam/` には何も作らない) - `--probe` は Background 型でつなぐだけで、オーバーレイも Vulkan も作らない。`FindOverlay`・名前・幅・閉じるボタン・表示中か・`GetOverlayTextureSize` を出す - `contrib/icons/frameeyeosc-panel-{48,128,256}.png` は `--thumbnail-png` で書き出したもの(ダッシュボードのサムネイルと同じ絵) @@ -268,13 +268,13 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - **シート**: 詳細タブの中身の代わりに出る(タブの列と左の列はそのまま。どのタブ・小タブを押しても閉じる。ダッシュボードを閉じても開いたまま)。上に「細めの学習用に撮る」と「半目の読み直し(AI)を、いろんな目で賢くするための撮影だよ」、右に × の[閉じる]。[撮る]で開くたびに、前の終わり・失敗の画面は忘れる。画面は `contrib::screen` が決める: - **同意**(`info.txt` に同意が無い・同意文の版〔`contrib::kConsentVersion`、今は 1〕が古い・眼鏡の答えが無いとき): 「「半目の読み直し(AI)」が細めや半目をもっと正しく見分けられるように、学習用の目の映像を録るよ。協力するかどうかは自由です。」、見出しつきで「録るもの」(目のカメラの映像を 1 回約 1 分半〔1 本 約 2.7 GB〕と、そのときの校正の数字と設定。送るまでは Frame の中だけ)・「使いみち」(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く)・「消してほしいとき」(言えば消す。もう配った AI に入った分は取り出せない)、警告色の枠で送り方の注意「目の映像は GitHub の issue や公開の場所に上げないでね。開発者に非公開で送ってね(送り方は開発者が伝えます)。」(連絡先はアプリに入れない)、灰色で「画面が白や黒に光ることはないよ(光なしで撮るよ)。」(光の注意は要らない)。[やめる]と[同意して次へ]。消したあとは上に緑で「録画を消したよ」 - **眼鏡・インサート**: 「HMD の中で、眼鏡や度付きインサートを使ってる?」「学習のときに分けて見るためだけに使うよ。録画ごとに info.txt に書くよ」、同じ重さの 4 つ[使ってない][眼鏡][度付きインサート][答えない]と[戻る]。答えたら、そのとき同意と答えを `info.txt` に書く(フォルダが無ければ作る) - - **撮る前**: 「細めの学習用の撮影(約 1 分半)」と点つきで「最初の 10 秒は、普通に開けて正面を見て待ってね」「ビープと画面の指示に合わせて、閉じる・普通・細める・普通・見開くを 3 回、そのあと上・下・左・右を見るよ」「細めるは、まぶしいときみたいに目を細める(閉じない)」「左・右は自分から見た向き。顔は正面のまま、目だけ動かしてね」「画面が白や黒に光ることはないよ」、録ったものがあれば灰色で「これまで 3 本・合計 8.2 GB」。ボタンの上に灰色で「空き容量 23.2 GB(1 本 約 2.7 GB)」(足りなければ赤で「空きが足りないよ(約 3.8 GB 要る)」、eyecam-rec がほかのことをしていれば「目のカメラがほかのことをしているあいだは待ってね」)、[撮影をはじめる(約 1 分半)]。押せるのは同意が済んで、eyecam-rec が待機中(かエラー)・コマンドの返事待ちでない・まとめていない・1 本ぶん(目のカメラの毎秒の枚数から。分からなければ 90)と 1 GiB が空いているとき(`contrib::startAllowed`) + - **撮る前**: 「細めの学習用の撮影(約 1 分半)」と点つきで「最初の 10 秒は、普通に開けて正面を見て待ってね」「ビープと画面の指示に合わせて、閉じる・普通・細める・普通・見開くを 3 回、そのあと上・下・左・右を見るよ」「細めるは、まぶしいときみたいに目を細める(閉じない)」「左・右は自分から見た向き。顔は正面のまま、目だけ動かしてね」「画面が白や黒に光ることはないよ」、録ったものがあれば灰色で「これまで 3 本・合計 8.2 GB」。ボタンの上に灰色で「空き容量 23.2 GB(1 本 約 2.7 GB)」(足りなければ赤で「空きが足りないよ(約 3.8 GB 要る)」、本体が止まっているか目のデータが来ていなければ赤で「目の送信が動いていて、目のデータが来ているときに撮れるよ(Valve の値も一緒に録るため)」、eyecam-rec がほかのことをしていれば「目のカメラがほかのことをしているあいだは待ってね」)、[撮影をはじめる(約 1 分半)]。押せるのは同意が済んで、eyecam-rec が待機中(かエラー)・コマンドの返事待ちでない・まとめていない・1 本ぶん(目のカメラの毎秒の枚数から。分からなければ 90)と 1 GiB が空いている・本体が動いていて目のデータが来ている(状態ファイルの `running` と `tracking`。Valve のサンプルは本体が頼んでいるあいだしか来ないので、無いまま撮ると 93 秒あとに「Valve の値が 0 %」で aborted になる)とき(`contrib::startAllowed`) - **録画中**: eyecam-rec が探している・録っているあいだは目の撮影の画面(`drawRun`)のまま、見出しだけ「細めの学習用に撮る」(その前後のつなぎ目は見出しと「送っています…」だけ)。ステップのチップは `protocol_contrib.txt` から(23 ステップ)。左右を見る段は「左の耳の方を見て」「右の耳の方を見て」(チップは「左を見る」「右を見る」)。シートを閉じていても、詳細タブの録画の画面は同じ見出しになる - - **録れなかった**: 赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった・ファイルが足りなかった〔そのファイル名〕・ファイルが途中で切れていた〔「空き容量を確かめて」〕・Valve の目のトラッキングの値が足りなかった〔フレームの何 % にあったか。「SteamVR の目のトラッキングが動いてるか確かめて」〕。どれも「もう一度撮ってね」)、録画のフォルダがあれば灰色で「この録画は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度撮る] + - **録れなかった**: 赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった・ファイルが足りなかった〔そのファイル名〕・片目か両目の映像が 1 枚もなかった〔「右目の映像がなかったよ。目のカメラの準備を確かめて」〕・ファイルが途中で切れていた〔「空き容量を確かめて」〕・Valve の目のトラッキングの値が足りなかった〔フレームの何 % にあったか。「SteamVR の目のトラッキングが動いてるか確かめて」〕。どれも「もう一度撮ってね」)、録画のフォルダがあれば灰色で「この録画は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度撮る] - **終わり**(撮れたあと、または[録ったもの]): 撮れたあとは緑のチェックと「撮れたよ。ありがとう!」、[録ったもの]なら「録ったもの」。「これまで 4 本・合計 10.9 GB」、灰色で「場所: ~/eyecam/contrib/」、撮れたあとは「別の日や、かぶり直したあとにもう一度押してくれると、もっと助かるよ」、送り方の注意の枠。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.7 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(4 本・6.3 GB)」とその下にファイル名(それより本数が増えるまでは押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。その下に灰色で「まとめたファイルは .tar.zst(7-Zip などで開けるよ)」(zstd が無くて .tar になるときは出さない)。まとめるときに学習に使えなかった録画は赤で「rec_… は学習に使えない形だったので入れなかったよ。もう一度撮ってね」。下の左に[録画を消す]、右に[閉じる] - **消す確認**: 赤い枠のカードで「撮った録画を全部消す?」「~/eyecam/contrib/ を中身ごと(10.9 GB)消します。元に戻せないよ。開発者に送ったあとで消してね。」、[やめる](アクセント)と[消す]。消している間は「消しています…」、消したら同意の画面(同意も `info.txt` と一緒に消える) -- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib/info.txt` を読み、シートを開くたびに読み直す(手で消したり変えたりしたフォルダもそのとおりに出る。追っている録画とまとめ・消すの仕事はそのまま)。[撮影をはじめる]は `ctl.sock` に `start contrib` を送り、そのときの値を覚える(下の `info.txt`)。終わりは `status.json` だけで見る(`contrib::RunWatch`): `contrib` の `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだもので学習に使えるものは `rename` でフォルダへ移し、撮り始めたときに読んだ `~/eyecam/calib_日時/calib_result.json` のいちばん新しいもの・`~/.config/eyecam/calib.json`(ユーザー校正)・`~/.config/eyecam/settings.json` の写しを、`rec_日時.calib_result.json`・`rec_日時.user_calib.json`・`rec_日時.settings.json` として隣に置く(無いものは `info.txt` に `none`)。学習に使える録画かは `contrib::checkRecording` が CSV の行と raw の大きさだけで見る(映像は読まない。3 分の録画でも数十 ms): `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` の 6 つがそろっている、目ごとの raw の大きさが `frames.csv` のその目の行数 × 1 フレーム(`meta.txt` の `frame_bytes`、無ければ 160000 B)とちょうど同じで 1 枚以上ある(ディスクがいっぱいで途中で切れた raw をはじく)、`frames.csv` のフレームの 9 割以上に、`t_cam` の前後 0.05 秒以内の使える Valve のサンプル(`valve.csv` の `producer_state` が 1 で `open_l`・`open_r` が数。`sample_time` は `t_cam` と同じ時計)がある(ラベルは Valve の開き具合からも作るので)。Frame の自分の録画 13 本はどれも通った。使えない・止まった・失敗したものは `aborted/` へ(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない)。パネルが録画の途中で起動し直したときは追わない(その録画は `~/eyecam/rec_日時/` に残る) -- **info.txt**(`~/eyecam/contrib/info.txt`): 初めに何のファイルかのコメントと `format=2`、そのあと出来事ごとに節を書き足す。`[consent]`(`time`・`version`・`eyewear`: `none` / `glasses` / `inserts` / `no_answer`。最後の節が効く)、`[recording]`(`rec`・`protocol`・`start`・`start_unix`・`end_unix`・`bytes`・`app_version`・`eyecam_rec_sha256`〔入っている eyecam-rec の SHA-256 の先頭 8 桁。git のハッシュは持っていない〕・`lid_veto`(`on` / `off` / `unknown`)・`widen`・`widen_sensitivity`・`camera_lids`・`eyewear`・`camera_fps`・`minutes_since_previous`(前の録画が終わってから何分。1 本目は `none`)・`headset_off`(そのあいだに HMD を外したか: `yes` / `no` / `unknown` / 1 本目は `none`)・`headset_off_count_start` / `_end`・`eyecam_started_unix`・`calib_result`・`user_calib`・`settings`)、`[aborted]`(`rec`・`why`: `stopped` / `failed` / `missing cues.csv` / `cut short: eye_R.raw` / `Valve's samples for only 40% of the frames`)、`[packed]`(`file`・`recordings`・`bytes`)。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) +- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib/info.txt` を読み、シートを開くたびに読み直す(手で消したり変えたりしたフォルダもそのとおりに出る。前の書き込みの失敗の文は消す。追っている録画とまとめ・消すの仕事はそのまま)。[撮影をはじめる]は `ctl.sock` に `start contrib` を送り、そのときの値を覚える(下の `info.txt`)。終わりは `status.json` だけで見る(`contrib::RunWatch`): `contrib` の `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだもので学習に使えるものは `rename` でフォルダへ移し、撮り始めたときに読んだ `~/eyecam/calib_日時/calib_result.json` のいちばん新しいもの・`~/.config/eyecam/calib.json`(ユーザー校正)・`~/.config/eyecam/settings.json` の写しを、`rec_日時.calib_result.json`・`rec_日時.user_calib.json`・`rec_日時.settings.json` として隣に置く(無いものは `info.txt` に `none`)。学習に使える録画かは `contrib::checkRecording` が CSV の行と raw の大きさだけで見る(映像は読まない。3 分の録画でも数十 ms): `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` の 6 つがそろっている、`frames.csv` に両目のフレームがある(片目でも 0 枚なら、その目の映像が来ていない)、目ごとの raw の大きさが `frames.csv` のその目の行数 × 1 フレーム(`meta.txt` の `frame_bytes`、無ければ 160000 B)とちょうど同じ(ディスクがいっぱいで途中で切れた raw をはじく)、`frames.csv` のフレームの 9 割以上に、`t_cam` の前後 0.05 秒以内の使える Valve のサンプル(`valve.csv` の `producer_state` が 1 で `open_l`・`open_r` が数。`sample_time` は `t_cam` と同じ時計)がある(ラベルは Valve の開き具合からも作るので)。Frame の自分の録画 13 本はどれも通った。使えない・止まった・失敗したものは `aborted/` へ(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない)。パネルが録画の途中で起動し直したときは追わない(その録画は `~/eyecam/rec_日時/` に残る) +- **info.txt**(`~/eyecam/contrib/info.txt`): 初めに何のファイルかのコメントと `format=2`、そのあと出来事ごとに節を書き足す。`[consent]`(`time`・`version`・`eyewear`: `none` / `glasses` / `inserts` / `no_answer`。最後の節が効く)、`[recording]`(`rec`・`protocol`・`start`・`start_unix`・`end_unix`・`bytes`・`app_version`・`eyecam_rec_sha256`〔入っている eyecam-rec の SHA-256 の先頭 8 桁。git のハッシュは持っていない〕・`lid_veto`(`on` / `off` / `unknown`)・`widen`・`widen_sensitivity`・`camera_lids`・`eyewear`・`camera_fps`・`minutes_since_previous`(前の録画が終わってから何分。1 本目は `none`)・`headset_off`(そのあいだに HMD を外したか: `yes` / `no` / `unknown` / 1 本目は `none`)・`headset_off_count_start` / `_end`・`eyecam_started_unix`・`calib_result`・`user_calib`・`settings`)、`[aborted]`(`rec`・`why`: `stopped` / `failed` / `missing cues.csv` / `no frames of eye R` / `cut short: eye_R.raw` / `Valve's samples for only 40% of the frames`)、`[packed]`(`file`・`recordings`・`bytes`)。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) - **外したかの見分け**: eyecam-rec の `status.json` の `headset_off_count`(起動してから、目の映像が 10 秒以上止まったか近接センサーが 10 秒以上「かぶっていない」になって、そのあとまたかぶった回数。サスペンドも数える時計で測る。tools/eyecam の README)と `started_unix` で決める。前の録画の終わりと今回の始まりで eyecam-rec が同じ起動(`started_unix` が同じ)なら、数が増えていれば `yes`、同じなら `no`。eyecam-rec が起動し直していた・古い eyecam-rec で値が無いときは `unknown`。続けて 2 回押しただけの録画を別のかぶりとして数えないため - **まとめる**(`contrib::Worker`、別のスレッド、nice 10): 別のスレッドの中で録画を同じ条件(`checkRecording`)で確かめ直してから、`info.txt`、使える録画と隣の写しだけを ustar の tar(中のフォルダ名は `contrib/`。8 GiB 以上の大きさは GNU の base-256)にして、zstd があれば `zstd -6 -T2` に通し、`contrib_日時.tar.zst`(無ければ `.tar`)にする。Frame の目の映像 2 GB で測ると、zstd -3 は 0.62 倍・7.7 秒、-6 は 0.58 倍・20 秒、-9 は 0.56 倍・43 秒、-12 は 0.55 倍・90 秒、zip -6 は 0.60 倍・166 秒、gzip -1 は 0.62 倍・19 秒だったので、-6 にした(SteamOS には zstd も tar も zip も入っている。7z は無い)。`.part` に書いて fsync してから名前を変え、失敗したら `.part` を消す。始める前に、その場で読み直した空きで確かめる(録画の 0.7 倍〔zstd なし: 1 倍〕と 1 GiB。前にまとめたファイルは今の空きにもう入っていない〔新しいのができてから消す〕ので、足さない)。できたら `[packed]` を書き足し、前にまとめたファイル(本数の少ないもの)は消す。元の録画には触らない - **消す**: 確認のあと、`~/eyecam/contrib` だけを(中のリンクはたどらずに)丸ごと消す diff --git a/panel/src/contrib.cpp b/panel/src/contrib.cpp index 97c6e33..6a451f7 100644 --- a/panel/src/contrib.cpp +++ b/panel/src/contrib.cpp @@ -515,10 +515,16 @@ CheckResult checkRecording(const std::string& dir) { while (std::getline(meta, line)) { if (line.rfind("frame_bytes=", 0) == 0) frameBytes = readInteger(line.substr(12), kRawFrameBytes); } + // An eye without a single frame: its camera's video didn't come (both eyes are needed) + if (count[0] == 0 || count[1] == 0) { + result.check = Check::NoVideo; + result.detail = std::string(count[0] == 0 ? "L" : "") + (count[1] == 0 ? "R" : ""); + return result; + } for (int eye = 0; eye < 2; ++eye) { const std::string raw = eye == 0 ? "eye_L.raw" : "eye_R.raw"; struct stat st {}; - if (::stat((dir + "/" + raw).c_str(), &st) != 0 || count[eye] == 0 || + if (::stat((dir + "/" + raw).c_str(), &st) != 0 || static_cast(st.st_size) != count[eye] * frameBytes) { result.check = Check::Truncated; result.detail += (result.detail.empty() ? "" : ", ") + raw; @@ -684,11 +690,11 @@ Screen screen(const View& view, Page page, bool agreed) { return view.justDone ? Screen::Done : Screen::Ready; } -bool startAllowed(const View& view, const eyecam::View& eyecam) { +bool startAllowed(const View& view, const eyecam::View& eyecam, bool eyeData) { const eyecam::State state = eyecam.status.state; return view.exists && !consentNeeded(view.info) && eyecam::setUp(eyecam) && !eyecam.busy && (state == eyecam::State::Idle || state == eyecam::State::Error) && !view.running && - !view.progress.running && fits(view.freeBytes, view.fps); + !view.progress.running && fits(view.freeBytes, view.fps) && eyeData; } bool packEntries(const std::string& dir, const Info& info, std::vector& entries, @@ -1036,6 +1042,8 @@ void Session::load(View& view, const std::string& home, const std::string& confi void Session::refresh(View& view) { if (view.home.empty()) return; + // (an earlier write's failure isn't shown again: the next one says so if it fails too) + view.error.clear(); view.exists = isRealDir(view.dir); view.freeBytes = freeBytes(view.home); view.info = Info(); @@ -1062,8 +1070,8 @@ bool Session::consent(View& view, Eyewear eyewear, std::time_t now) { return true; } -std::string Session::startCommand(const View& view, const eyecam::View& eyecam) const { - if (!startAllowed(view, eyecam) || !command_.empty()) return ""; +std::string Session::startCommand(const View& view, const eyecam::View& eyecam, bool eyeData) const { + if (!startAllowed(view, eyecam, eyeData) || !command_.empty()) return ""; return std::string(eyecam::kStartCommand) + " " + kProtocol; } @@ -1252,10 +1260,12 @@ void Session::ended(View& view, const RunEnd& end, const eyecam::View& eyecam, d std::string why = end.outcome == Outcome::Stopped ? "stopped" : "failed"; if (end.outcome == Outcome::Done) { const Why reason = check.check == Check::Missing ? Why::Incomplete + : check.check == Check::NoVideo ? Why::NoVideo : check.check == Check::Truncated ? Why::Truncated : Why::Valve; view.failure = {true, reason, check.detail, check.detail, ""}; why = check.check == Check::Missing ? "missing " + check.detail + : check.check == Check::NoVideo ? "no frames of eye " + check.detail : check.check == Check::Truncated ? "cut short: " + check.detail : "Valve's samples for only " + check.detail + " of the frames"; } diff --git a/panel/src/contrib.h b/panel/src/contrib.h index e3388ba..8b83a3b 100644 --- a/panel/src/contrib.h +++ b/panel/src/contrib.h @@ -267,6 +267,7 @@ std::vector missingFiles(const std::string& dir); enum class Check { Ok, Missing, ///< a file of kRequiredFiles isn't there + NoVideo, ///< an eye has no frame at all (its camera's video didn't come) Truncated, ///< an eye's raw file doesn't hold as many frames as frames.csv lists for it (the disk ran full) Valve, ///< under kValveShare of the frames have a usable Valve sample near them (Valve's tracking off?) }; @@ -274,15 +275,16 @@ enum class Check { /** checkRecording's answer. */ struct CheckResult { Check check = Check::Ok; - std::string detail; ///< the files missing, the raw files cut short ("eye_L.raw"), or the share paired ("42%") + std::string detail; ///< the files missing, the eyes without frames ("L", "R", "LR"), the raw files cut short + ///< ("eye_L.raw"), or the share paired ("42%") }; /** * Whether a recording can be used for training, from its CSV files and its raw files' sizes alone (never the - * frames): all of kRequiredFiles there; each eye's raw file exactly as many frames (meta.txt's frame_bytes each, else - * kRawFrameBytes) as frames.csv has rows for that eye, and at least one; and at least kValveShare of frames.csv's - * frames with a usable Valve sample in valve.csv (producer_state 1, both openness values numbers) within - * kValvePairSec of their t_cam (sample_time is on the same clock). + * frames): all of kRequiredFiles there; frames of both eyes in frames.csv; each eye's raw file exactly as many + * frames (meta.txt's frame_bytes each, else kRawFrameBytes) as frames.csv has rows for that eye; and at least + * kValveShare of frames.csv's frames with a usable Valve sample in valve.csv (producer_state 1, both openness values + * numbers) within kValvePairSec of their t_cam (sample_time is on the same clock). * @param dir its folder * @return the answer */ @@ -328,7 +330,7 @@ enum class Outcome { }; /** Why a try didn't finish (the screen says it). */ -enum class Why { None, Stopped, Error, Refused, Gone, NoStart, Missing, Move, Incomplete, Truncated, Valve }; +enum class Why { None, Stopped, Error, Refused, Gone, NoStart, Missing, Move, Incomplete, NoVideo, Truncated, Valve }; /** A recording's end. */ struct RunEnd { @@ -455,12 +457,15 @@ Screen screen(const View& view, Page page, bool agreed); /** * Whether "Start" can be pressed: the consent given, eyecam-rec there and idle (or in error), set up, no command - * waiting, nothing running or being packed, and the recording fits. + * waiting, nothing running or being packed, the recording fits, and frameeyeosc running with eye data coming in + * (Valve's samples, which a recording needs for its labels, only come while frameeyeosc asks for them: without them + * it would end in aborted/ after its 93 s). * @param view the contribution * @param eyecam eyecam-rec + * @param eyeData frameeyeosc runs and eye data comes in (EyeStatus running and tracking) * @return true if it can */ -bool startAllowed(const View& view, const eyecam::View& eyecam); +bool startAllowed(const View& view, const eyecam::View& eyecam, bool eyeData); /** One file or folder in the pack. */ struct Entry { @@ -617,9 +622,10 @@ class Session { * The command that starts a recording, if it can start now (startAllowed). * @param view the contribution * @param eyecam eyecam-rec + * @param eyeData frameeyeosc runs and eye data comes in * @return "start contrib", or "" when it can't */ - std::string startCommand(const View& view, const eyecam::View& eyecam) const; + std::string startCommand(const View& view, const eyecam::View& eyecam, bool eyeData) const; /** * The start went out: follow its recording, with what its section will say about the time of the start. diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index 36383ca..fe6cd8f 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -924,6 +924,7 @@ UiText makeJapanese() { t.contribStart = "撮影をはじめる(約 1 分半)"; t.contribNoSpaceFormat = "空きが足りないよ(約 %s 要る)"; t.contribWaitEyecam = "目のカメラがほかのことをしているあいだは待ってね"; + t.contribNoEyeData = "目の送信が動いていて、目のデータが来ているときに撮れるよ(Valve の値も一緒に録るため)"; t.contribFailedTitle = "最後まで録れなかったよ"; t.contribWhyStopped = "途中で止まったよ(中止したか、目が見えなくなったみたい)"; t.contribWhyErrorFormat = "目の撮影でエラー: %s"; @@ -933,6 +934,8 @@ UiText makeJapanese() { t.contribWhyMissing = "録画のフォルダが見つからなかったよ"; t.contribWhyMoveFormat = "録画を移せなかった: %s"; t.contribWhyIncompleteFormat = "録画のファイルが足りなかったよ(%s)。もう一度撮ってね"; + t.contribWhyNoVideoFormat = "%sの映像がなかったよ。目のカメラの準備を確かめて、もう一度撮ってね"; + t.contribBothEyes = "両目"; t.contribWhyTruncatedFormat = "録画のファイルが途中で切れていたよ(%s)。空き容量を確かめて、もう一度撮ってね"; t.contribWhyValveFormat = "Valve の目のトラッキングの値が、フレームの %s にしかなかったよ。" "SteamVR の目のトラッキングが動いてるか確かめて、もう一度撮ってね"; @@ -1979,6 +1982,7 @@ UiText makeEnglish() { t.contribStart = "Start recording (about 1.5 minutes)"; t.contribNoSpaceFormat = "Not enough space (about %s needed)"; t.contribWaitEyecam = "Wait while the eye cameras are busy with something else"; + t.contribNoEyeData = "Records while frameeyeosc runs and eye data comes in (Valve's values are recorded too)"; t.contribFailedTitle = "It didn't record to the end"; t.contribWhyStopped = "It stopped early (stopped, or the eyes were lost)"; t.contribWhyErrorFormat = "Eye recording error: %s"; @@ -1988,6 +1992,8 @@ UiText makeEnglish() { t.contribWhyMissing = "The recording's folder wasn't found"; t.contribWhyMoveFormat = "Couldn't move the recording: %s"; t.contribWhyIncompleteFormat = "The recording lacks files (%s). Please record again"; + t.contribWhyNoVideoFormat = "No video from an eye camera (%s). Check the eye cameras' setup and record again"; + t.contribBothEyes = "both"; t.contribWhyTruncatedFormat = "The recording's files were cut short (%s). Check the free space and record again"; t.contribWhyValveFormat = "Valve's eye tracking values were there for only %s of the frames. Check that " "SteamVR's eye tracking is on and record again"; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index 4f99124..b974ba8 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -919,6 +919,7 @@ struct UiText { const char* contribStart; ///< ...its button const char* contribNoSpaceFormat; ///< ...the recording doesn't fit (%s needed) const char* contribWaitEyecam; ///< ...eyecam-rec busy with something else + const char* contribNoEyeData; ///< ...frameeyeosc not running or no eye data (Valve's values are recorded too) const char* contribFailedTitle; ///< a try that didn't finish const char* contribWhyStopped; const char* contribWhyErrorFormat; ///< (%s: eyecam-rec's message) @@ -928,6 +929,8 @@ struct UiText { const char* contribWhyMissing; const char* contribWhyMoveFormat; ///< (%s: why) const char* contribWhyIncompleteFormat; ///< (%s: the files missing) + const char* contribWhyNoVideoFormat; ///< (%s: the eye without frames: leftEye, rightEye or contribBothEyes) + const char* contribBothEyes; ///< ...both const char* contribWhyTruncatedFormat; ///< (%s: the raw files cut short) const char* contribWhyValveFormat; ///< (%s: the share of frames with Valve's values, "42%") const char* contribKept; ///< ...its part went to aborted/ diff --git a/panel/src/main.cpp b/panel/src/main.cpp index a8cb0a1..b5ab228 100644 --- a/panel/src/main.cpp +++ b/panel/src/main.cpp @@ -386,8 +386,9 @@ void printUsage() { " --fake-password set|unset|unknown The setup's password check (default: set)\n" " --fake-contrib STATE \"Record for squint training\" open on a made-up screen (the eye cameras set up\n" " and idle unless --fake-eyecam says otherwise): consent, deleted, eyewear,\n" - " ready, nospace, running, failed, incomplete, truncated, valve (not usable:\n" - " a file missing, a raw file cut short, Valve's values missing), done (just\n" + " ready, nospace, noeyedata (frameeyeosc gets no eye data), running, failed,\n" + " incomplete, novideo, truncated, valve (not usable: a file missing, an eye's video\n" + " missing, a raw file cut short, Valve's values missing), done (just\n" " recorded), list (\"Recordings\"), packing, packed, delete, deleting (with --adv-page tools: its\n" " row on Debug tools, the sheet closed; none for the row before the first)\n" " --fake-camera both|left|right|uncalibrated|absent|error|off The eye cameras as frameeyeosc reports\n" @@ -461,7 +462,8 @@ bool parsePreviewSpec(const std::string& text, eye_preview::Source& source, bool /** --fake-contrib's screens (fakeContrib). */ const std::string kFakeContribStates[] = {"none", "consent", "deleted", "eyewear", "ready", "nospace", - "running", "failed", "incomplete", "truncated", "valve", "done", + "running", "failed", "incomplete", "novideo", "truncated", "valve", + "noeyedata", "done", "list", "packing", "packed", "delete", "deleting"}; /** --fake-eyecam, taken apart. */ @@ -1707,6 +1709,10 @@ void fakeContrib(PanelModel& m, const std::string& state, bool eyecamGiven) { if (state == "incomplete") { c.failure = {true, contrib::Why::Incomplete, "cues.csv", "cues.csv", "aborted/rec_2026-10-25_14-20-55"}; } + if (state == "novideo") { + c.failure = {true, contrib::Why::NoVideo, "R", "R", "aborted/rec_2026-10-25_14-20-55"}; + } + if (state == "noeyedata") m.status.tracking = false; if (state == "truncated") { c.failure = {true, contrib::Why::Truncated, "eye_R.raw", "eye_R.raw", "aborted/rec_2026-10-25_14-20-55"}; } @@ -3340,7 +3346,8 @@ void applyHit(const PanelHit& hit, PanelModel& model, EyePanel& panel, Autostart std::printf("contrib: would send \"start %s\"\n", contrib::kProtocol); return; } - const std::string command = contribSession->startCommand(model.contrib, model.eyecam); + const bool eyeData = model.status.running && model.status.tracking; + const std::string command = contribSession->startCommand(model.contrib, model.eyecam, eyeData); if (command.empty()) { std::fprintf(stderr, "[contrib] can't start now\n"); return; diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index 5e4f17a..7267835 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -7044,6 +7044,8 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) const double bottom = kContentY + kContentH - 20; const bool jobRunning = c.progress.running; const int count = static_cast(c.info.recordings.size()); + // (Valve's samples, which the labels need, only come while frameeyeosc runs and gets eye data) + const bool eyeData = m.status.running && m.status.tracking; // The title and the cross const double closeS = 52; @@ -7104,7 +7106,10 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) const long long need = contrib::estimateBytes(c.fps) + contrib::kMarginBytes; pen.text(kInnerX, mainY - 16, formatLong(t.contribNoSpaceFormat, contrib::sizeText(need)), 16, kDanger, true); - } else if (!contrib::startAllowed(c, m.eyecam)) { + } else if (!eyeData) { + pen.text(kInnerX, mainY - 16, ellipsize(pen, t.contribNoEyeData, 16, false, width, false), 16, kDanger, + true); + } else if (!contrib::startAllowed(c, m.eyecam, eyeData)) { pen.text(kInnerX, mainY - 16, ellipsize(pen, t.contribWaitEyecam, 16, false, width, false), 16, kTextMuted); } else if (c.freeBytes >= 0) { @@ -7190,8 +7195,8 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) paragraph(countLine(), 16, kTextMuted, false, 1); } startState(); - mainButton(t.contribStart, {PanelAction::ContribStart, nullptr, 0}, contrib::startAllowed(c, m.eyecam), - true); + mainButton(t.contribStart, {PanelAction::ContribStart, nullptr, 0}, + contrib::startAllowed(c, m.eyecam, eyeData), true); break; } case Screen::Failed: { @@ -7210,6 +7215,11 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) case contrib::Why::Missing: why = t.contribWhyMissing; break; case contrib::Why::Move: why = formatLong(t.contribWhyMoveFormat, message); break; case contrib::Why::Incomplete: why = formatLong(t.contribWhyIncompleteFormat, message); break; + case contrib::Why::NoVideo: + why = formatLong(t.contribWhyNoVideoFormat, message == "L" ? t.leftEye + : message == "R" ? t.rightEye + : t.contribBothEyes); + break; case contrib::Why::Truncated: why = formatLong(t.contribWhyTruncatedFormat, message); break; case contrib::Why::Valve: why = formatLong(t.contribWhyValveFormat, message); break; } @@ -7219,8 +7229,8 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) paragraph(t.contribKept, 16, kTextMuted, false, 2); } startState(); - mainButton(t.contribRetry, {PanelAction::ContribStart, nullptr, 0}, contrib::startAllowed(c, m.eyecam), - true); + mainButton(t.contribRetry, {PanelAction::ContribStart, nullptr, 0}, + contrib::startAllowed(c, m.eyecam, eyeData), true); break; } case Screen::Done: { diff --git a/panel/tests/contrib_test.cpp b/panel/tests/contrib_test.cpp index bb47ee4..f9512fd 100644 --- a/panel/tests/contrib_test.cpp +++ b/panel/tests/contrib_test.cpp @@ -524,44 +524,46 @@ void testScreen() { CHECK(screen(v, Page::Record, false) == Screen::Consent); CHECK(screen(v, Page::Record, true) == Screen::Eyewear); CHECK(screen(v, Page::Manage, false) == Screen::Done); - CHECK(!startAllowed(v, ready)); + CHECK(!startAllowed(v, ready, true)); v.exists = true; v.info.consentVersion = kConsentVersion; v.info.eyewear = Eyewear::None; v.freeBytes = 20'000'000'000LL; CHECK(screen(v, Page::Record, false) == Screen::Ready); CHECK(screen(v, Page::Record, true) == Screen::Ready); - CHECK(startAllowed(v, ready)); + CHECK(startAllowed(v, ready, true)); // eyecam-rec busy, not set up, a command waiting, no room, packing: no start { eyecam::View busy = ready; busy.status.state = State::Calibrating; - CHECK(!startAllowed(v, busy)); + CHECK(!startAllowed(v, busy, true)); eyecam::View waiting = ready; waiting.busy = true; - CHECK(!startAllowed(v, waiting)); + CHECK(!startAllowed(v, waiting, true)); eyecam::View notSetUp = ready; notSetUp.status.setupDone = false; notSetUp.status.calibState = 0; - CHECK(!startAllowed(v, notSetUp)); + CHECK(!startAllowed(v, notSetUp, true)); eyecam::View error = ready; error.status.state = State::Error; - CHECK(startAllowed(v, error)); + CHECK(startAllowed(v, error, true)); View full = v; full.freeBytes = 3'000'000'000LL; - CHECK(!startAllowed(full, ready)); + CHECK(!startAllowed(full, ready, true)); View packing = v; packing.progress.running = true; - CHECK(!startAllowed(packing, ready)); + CHECK(!startAllowed(packing, ready, true)); + // frameeyeosc stopped, or no eye data: Valve's samples wouldn't come (the recording would end in aborted/) + CHECK(!startAllowed(v, ready, false)); View older = v; older.info.consentVersion = kConsentVersion - 1; - CHECK(!startAllowed(older, ready)); + CHECK(!startAllowed(older, ready, true)); CHECK(screen(older, Page::Record, false) == Screen::Consent); } // Running, then how it ended v.running = true; CHECK(screen(v, Page::Record, false) == Screen::Running); - CHECK(!startAllowed(v, ready)); + CHECK(!startAllowed(v, ready, true)); v.running = false; v.failure.shown = true; CHECK(screen(v, Page::Record, false) == Screen::Failed); @@ -708,9 +710,21 @@ void testCheck() { r = checkRecording(makeRecording(home, "rec_2026-10-25_14-00-04", 50, kNow, Defect::LongerRaw)); CHECK(r.check == Check::Truncated); SAME(r.detail, "eye_L.raw"); + // No frame of an eye at all: its video didn't come (not the disk), which eye r = checkRecording(makeRecording(home, "rec_2026-10-25_14-00-05", 50, kNow, Defect::NoFrames)); - CHECK(r.check == Check::Truncated); - SAME(r.detail, "eye_L.raw, eye_R.raw"); + CHECK(r.check == Check::NoVideo); + SAME(r.detail, "LR"); + const std::string oneEye = makeRecording(home, "rec_2026-10-25_14-00-10"); + std::string rows; + std::istringstream frames(readFile(oneEye + "/frames.csv")); + for (std::string line; std::getline(frames, line);) { + if (line.find(",R,") == std::string::npos) rows += line + "\n"; + } + writeFile(oneEye + "/frames.csv", rows); + writeFile(oneEye + "/eye_R.raw", ""); + r = checkRecording(oneEye); + CHECK(r.check == Check::NoVideo); + SAME(r.detail, "R"); // meta.txt's frame_bytes counts; without it, eyecam-rec's 160000 const std::string sized = makeRecording(home, "rec_2026-10-25_14-00-06"); writeFile(sized + "/meta.txt", "protocol=contrib\n"); @@ -939,7 +953,7 @@ void record(contrib::Session& session, contrib::View& view, const std::string& h snapshot.appVersion = "0.8.2-test"; snapshot.eyecamRec = "294d06c6"; eyecam.status.mtime = now; - const std::string command = session.startCommand(view, eyecam); + const std::string command = session.startCommand(view, eyecam, true); SAME(command, "start contrib"); session.started(view, eyecam, snapshot, command, now); eyecam::Reply ok; @@ -982,7 +996,7 @@ void testSession() { double now = static_cast(std::time(nullptr)); const double started = now - 3600; // No start before the consent, nor without an answer - CHECK(session.startCommand(view, eyecamReady(now)).empty()); + CHECK(session.startCommand(view, eyecamReady(now), true).empty()); CHECK(!session.consent(view, Eyewear::Unasked, static_cast(now))); CHECK(session.consent(view, Eyewear::Glasses, static_cast(now))); CHECK(view.exists && view.info.eyewear == Eyewear::Glasses && !consentNeeded(view.info)); @@ -1066,6 +1080,12 @@ void testSession() { CHECK(!view.running && view.failure.why == Why::Refused); SAME(view.failure.message, refused.error); } + // No start without frameeyeosc's eye data + CHECK(session.startCommand(view, eyecamReady(now), false).empty()); + // A failed write's message goes once the folder is read again + view.error = "can't write ~/eyecam/contrib/info.txt: No space left on device"; + session.refresh(view); + CHECK(view.error.empty()); // Read again from the disk: the same { View again; diff --git a/panel/tests/panel_test.cpp b/panel/tests/panel_test.cpp index 1402ad2..0cca8d7 100644 --- a/panel/tests/panel_test.cpp +++ b/panel/tests/panel_test.cpp @@ -3027,6 +3027,7 @@ void testContrib(const FontSet& fonts) { m.contrib.loaded = true; m.contrib.dir = "/home/steamos/eyecam/contrib"; m.contrib.freeBytes = 30'000'000'000LL; + m.status.tracking = true; panel.render(m); CHECK(hitsArg(panel, PanelAction::ContribOpen, record) == 1); CHECK(hitsArg(panel, PanelAction::ContribOpen, manage) == 0); @@ -3061,6 +3062,11 @@ void testContrib(const FontSet& fonts) { panel.render(m); CHECK(hits(panel, PanelAction::ContribStart).empty()); m.contrib.freeBytes = 30'000'000'000LL; + // ...nor without frameeyeosc's eye data (Valve's values are recorded with it) + m.status.tracking = false; + panel.render(m); + CHECK(hits(panel, PanelAction::ContribStart).empty()); + m.status.tracking = true; // Its recording runs in the sheet: the eye capture's run with its "Stop" m.contrib.running = true; eyecam::Status& s = m.eyecam.status; From 7efe7ecbe2bb572c4061c6c95943d349dcb467b1 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 16:55:51 +0900 Subject: [PATCH 42/65] =?UTF-8?q?eyecam:=20=E3=81=8B=E3=81=B6=E3=82=8A?= =?UTF-8?q?=E6=A0=A1=E6=AD=A3=E3=81=AE=E8=A6=8B=E9=96=8B=E3=81=8D=E3=81=AE?= =?UTF-8?q?=E9=AB=98=E3=81=95=E3=82=92=E3=80=81=E8=A6=8B=E9=96=8B=E3=81=8D?= =?UTF-8?q?=E3=81=AE=E6=AE=B5=E3=81=94=E3=81=A8=E3=81=AE=E4=B8=AD=E5=A4=AE?= =?UTF-8?q?=E5=80=A4=E3=81=AE=E3=81=86=E3=81=A1=E4=B8=80=E7=95=AA=E4=BD=8E?= =?UTF-8?q?=E3=81=84=E3=82=82=E3=81=AE=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 段ごとの中央値は、線の取れたフレームが 30 以上の段だけで出す(どの段も足りなければ今までどおり全部の中央値)。 段の区切りは見開き以外のフレームか 0.5 s を超える間 - 度付きインサートのテスターの録画(2026-10-23): 左目の線が 1 回目の見開きだけ 1.81 R、2・3 回目は 1.11 / 1.08 R で、 全部の中央値の幅 0.52 には 2・3 回目が届かなかった。幅は L 0.515 -> 0.248、R 0.505 -> 0.435。 そのテスターの設定で frameeyeosc まで通すと、見開きの段で 0.5 以上が 36 / 39 %(感度 0.5 / 1)-> 91 %。 普段・細めの段は 0 % のまま、上を見たとき 5 / 9 % -> 7 / 12 %、下を見たときは感度 0.5 で 0 %、感度 1 で 20 % - 開発者の 27 回のかぶり校正: 幅は同じか小さくなるだけ(中央値 -0.011、最大 -0.055)、ほかの値は同じ - calib_result.json に目ごとの widen_step_levels、ログに widen by step を足す。ライブのエンジンは変えていない Co-Authored-By: Claude Opus 5.5 --- tools/eyecam/README.md | 3 + tools/eyecam/src/live.rs | 122 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index 652c4b5..dd90add 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -644,6 +644,9 @@ python3 ~/eyecam-src/tools/drift_report.py ~/eyecam/drift/drift_….csv # 5 上がったとみて(度付きインサートの人の長いまつ毛の先: 普段 1.69 → 見開き 2.01)、見開きの幅はその上がった分にする(`widen_upper_rose_from` にその中央値)。目ごとの `widen_source` (`main` / `upper` / `fallback`)と数(`widen_frames_no_line_near_normal`・`widen_frames_upper_good`・`widen_frames_upper_used`・ `widen_upper_fixed_line`・`widen_upper_rose_from`)が calib_result.json に入る。 + 普段の範囲の線で測るとき(`main` / `fallback`)、見開きの高さは見開きの段ごとの中央値のうち一番低いもの(線の取れたフレームが 30 以上の段だけ。 + どの段も足りなければ全部の中央値)。どこかの段で線が高く読まれても、ライブでは毎回届く幅にするため(度付きインサートの人の左目: 1 回目 1.81、 + 2・3 回目 1.11 / 1.08)。段ごとの中央値は目ごとの `widen_step_levels` とログの `widen by step` に出る。 wear の `calib_result.json` には目ごとに瞳の探し方の診断 `diag` も入る(目の画像がなくても、瞳が見えなかった原因をこれで見分ける): `fps`(フレームレート)、`search_x_min` / `search_xmax`(使った枠の左右)、`normal_pupil_x` / `normal_pupil_y`(普段の段の瞳の位置の中央値)、 `normal_contrast`(普段の段の、枠の明るさの中央値 − 暗いところ。開発者で 60〜100)、`steps`(段ごとに `frames`・`pupil`・`lid_line`、 diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index f9100a9..7106bdc 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -2347,11 +2347,58 @@ pub const MIN_OPEN_GAP: f64 = 0.2; /// look down), so leaving them out is right either way. pub const WIDEN_MIN_BELOW_N: f64 = 0.2; -/// The widen level of the widen frames among `s`: the median upper lid line of those with the pupil and a line no -/// more than WIDEN_MIN_BELOW_N below the normal level `b_n`, and how many those are (they are what NEED_WIDEN counts, -/// so too few of them falls back to the usual widen step as before). +/// A widen step (one cue) counts toward the widen level on its own only with at least this many widen frames that have +/// the lid line (`widen_level`); fewer, and it only counts in the median over all of them. A calibration's widen steps +/// have 198 frames at 90 fps after STEP_DELAY (33 at 15 fps). +pub const WIDEN_STEP_MIN_FRAMES: usize = 30; +/// Widen frames further apart than this (s) belong to different widen steps, even with no other frame between them. +const WIDEN_STEP_GAP_S: f64 = 0.5; + +/// The widen frames' lid lines that count toward the widen level: those with the pupil and a line no more than +/// WIDEN_MIN_BELOW_N below the normal level `b_n`. +fn widen_line(x: &Sample, b_n: f64) -> Option { + (x.label == Label::Widen && x.ok && x.skin >= b_n - WIDEN_MIN_BELOW_N).then_some(x.skin) +} + +/// The median lid line (`widen_line`) of each widen step among `s` with at least WIDEN_STEP_MIN_FRAMES of them, in +/// order. A step ends at any frame that is not a widen frame, or at a gap of more than WIDEN_STEP_GAP_S. +pub fn widen_step_levels(s: &[Sample], b_n: f64) -> Vec { + let mut steps: Vec> = Vec::new(); + let mut last_t = f64::NAN; + let mut in_step = false; + for x in s { + if x.label != Label::Widen { + in_step = false; + continue; + } + if !in_step || x.t - last_t > WIDEN_STEP_GAP_S { + steps.push(Vec::new()); + in_step = true; + } + last_t = x.t; + if let Some(v) = widen_line(x, b_n).filter(|v| v.is_finite()) { + steps.last_mut().expect("a step was started").push(v); + } + } + steps.iter_mut().filter(|v| v.len() >= WIDEN_STEP_MIN_FRAMES).map(|v| vision::median(v)).collect() +} + +/// The widen level of the widen frames among `s` (`widen_line`), and how many frames have one (they are what NEED_WIDEN +/// counts, so too few of them falls back to the usual widen step as before). The level is the lowest of the widen +/// steps' own medians (`widen_step_levels`), or the median over all of them when no step has WIDEN_STEP_MIN_FRAMES. +/// Live, the step of one wear's calibration has to be reached every time, while one widen step can read higher than +/// the others: a tester with prescription inserts (a recording of 2026-10-23, three 5 s widen steps) had the left eye's +/// line at 1.81 R in the first step and at 1.11 / 1.08 R in the other two, against 0.83 R normal; the median over all +/// of them gave a step of 0.52 that the other two steps rose only 0.4 / 0.3 of. Replayed live and through frameeyeosc +/// with that wearer's settings, widening went out at 0.5 or more in 36 % / 39 % of the widen frames (both eyes, widen +/// sensitivity 0.5 / 1). With the lowest step's median the step is 0.25 (the right eye's 0.51 -> 0.43) and 91 % at +/// either; nothing widened (0.3 or more) in the normal and squint steps, looking up 7 / 12 % (5 / 9 % before), looking +/// down none at sensitivity 0.5 but 20 % at 1. On the developer's 27 wear calibrations the step stays the same or gets +/// smaller, by 0.011 in the median and 0.055 at most, and nothing else in them changes. fn widen_level(s: &[Sample], b_n: f64) -> (f64, usize) { - med_of(s, |x| (x.label == Label::Widen && x.ok && x.skin >= b_n - WIDEN_MIN_BELOW_N).then_some(x.skin)) + let (all, n) = med_of(s, |x| widen_line(x, b_n)); + let lowest = widen_step_levels(s, b_n).into_iter().fold(f64::INFINITY, f64::min); + (if lowest.is_finite() { lowest } else { all }, n) } /// When at least this share of an eye's widen frames with the pupil have no upper lid line within WIDEN_MIN_BELOW_N of @@ -2615,6 +2662,14 @@ pub fn wear_report(samples: &[Vec; 2]) -> (String, Vec) { let wf = widen_fit(&s, b_n, need.widen); let (b_w, nw) = (wf.b_w, wf.n); let below = s.iter().filter(|x| x.label == Label::Widen && x.ok && x.skin < b_n - WIDEN_MIN_BELOW_N).count(); + // Each widen step's own level in the usual band (the lowest is the widen level when the source is "main") + let step_levels = widen_step_levels(&s, b_n); + let levels_json = step_levels.iter().map(|v| json::num(*v)).collect::>().join(", "); + let levels_text = if step_levels.is_empty() { + "none".to_string() + } else { + step_levels.iter().map(|v| format!("{v:.3}")).collect::>().join(" / ") + }; let (ap_n, _) = med_of(&s, |x| (x.label == Label::Normal && x.ok).then_some(x.ap)); let (ap_cl, nc) = med_of(&s, |x| (x.label == Label::Close).then_some(x.ap)); let all = |l: Label| s.iter().filter(|x| x.label == l).count(); @@ -2628,7 +2683,7 @@ pub fn wear_report(samples: &[Vec; 2]) -> (String, Vec) { \"normal_frames_with_pupil\": {n_pupil}, \"normal_frames_with_lid_line\": {nn}, \"widen_frames_with_pupil\": {nw}, \"widen_frames_below_normal\": {below}, \"close_frames_with_lids\": {nc}, \ \"pupil_seen_share\": {{\"normal\": {}, \"widen\": {}, \"close\": {}}}, \"diag\": {}, \ \"widen_source\": \"{}\", \"widen_frames_no_line_near_normal\": {}, \"widen_frames_upper_good\": {}, \"widen_frames_upper_used\": {}, \ -\"widen_upper_fixed_line\": {}, \"widen_upper_rose_from\": {}}}", +\"widen_upper_fixed_line\": {}, \"widen_upper_rose_from\": {}, \"widen_step_levels\": [{levels_json}]}}", ["L", "R"][e], json::num(r_px), json::num(b_n), @@ -2650,7 +2705,7 @@ pub fn wear_report(samples: &[Vec; 2]) -> (String, Vec) { )); lines.push(format!( "calib wear {}: widen step {:.3} (needs >= {MIN_WIDEN_STEP}, <= {MAX_WIDEN_STEP}; normal {:.3}, widen {:.3}), open gap {:.3} (needs >= {MIN_OPEN_GAP}), \ -frames with pupil normal {n_pupil} of {}, with the upper lid line {nn} / widen {nw} (need {} / {}), close {nc} (need {}; at {:.0} fps), widen below normal left out {below}, widen from {} (no line near normal {} of {}, upper good {} / used {}{}), R {:.1} px; {}", +frames with pupil normal {n_pupil} of {}, with the upper lid line {nn} / widen {nw} (need {} / {}), close {nc} (need {}; at {:.0} fps), widen below normal left out {below}, widen by step {levels_text}, widen from {} (no line near normal {} of {}, upper good {} / used {}{}), R {:.1} px; {}", ["L", "R"][e], b_w - b_n, b_n, @@ -3009,6 +3064,61 @@ mod tests { assert!(wear_report(&[usual.clone(), usual]).0.contains("\"widen_frames_below_normal\": 0")); } + /// One eye's wear steps: normal 1.0 R, then widen steps at the given (frames, line) with 100 normal frames before + /// each, then closed 0.56; times run on at 90 fps. + fn wear_eye_with_widen_steps(steps: &[(usize, f64)]) -> Vec { + let mut eye = samples(Label::Normal, 300, 1.0, 1.7, -14.0, 250.0, true); + for &(n, skin) in steps { + eye.extend(samples(Label::Normal, 100, 1.0, 1.7, -14.0, 250.0, true)); + eye.extend(samples(Label::Widen, n, skin, 2.4, -14.0, 252.0, true)); + } + eye.extend(samples(Label::Close, 80, f64::NAN, 0.56, -14.0, 240.0, false)); + for (i, x) in eye.iter_mut().enumerate() { + x.t = i as f64 / 90.0; + } + eye + } + + #[test] + fn the_widen_level_is_the_lowest_widen_steps_own() { + // A tester with prescription inserts (2026-10-23): the left eye's line at 1.81 R in the first widen step and at + // 1.11 / 1.08 R in the other two. Here the median over all of them would be 1.11, a step of 0.11 that the third + // step rises only 0.08 of; the lowest step's median is 1.08. + let eye = wear_eye_with_widen_steps(&[(198, 1.81), (198, 1.11), (198, 1.08)]); + assert_eq!(widen_step_levels(&eye, 1.0), vec![1.81, 1.11, 1.08]); + assert_eq!(widen_level(&eye, 1.0), (1.08, 594), "every frame with a line still counts toward NEED_WIDEN"); + let both = [eye.clone(), eye]; + let (w, measured) = fit_wear(&both, [0.19; 2]).unwrap(); + assert!(measured && (w[0].b_w - 1.08).abs() < 1e-9, "{:?}", w[0]); + let (report, lines) = wear_report(&both); + assert!(report.contains("\"widen_step\": 0.08") && report.contains("\"widen_step_levels\": [1.81, 1.11, 1.08]"), "{report}"); + assert!(lines[0].contains("widen below normal left out 0, widen by step 1.810 / 1.110 / 1.080, widen from main"), "{}", lines[0]); + // A step with fewer than WIDEN_STEP_MIN_FRAMES lines (a widen held for a moment, or mostly lines below normal) + // does not decide it; its frames still count in the median over all. + let short = wear_eye_with_widen_steps(&[(29, 1.05), (198, 1.3), (198, 1.4)]); + assert_eq!((widen_step_levels(&short, 1.0), widen_level(&short, 1.0)), (vec![1.3, 1.4], (1.3, 425))); + let mut low = wear_eye_with_widen_steps(&[(198, 1.3), (198, 1.4)]); + for x in low.iter_mut().filter(|x| x.label == Label::Widen && x.skin == 1.3).skip(WIDEN_STEP_MIN_FRAMES - 1) { + x.skin = 0.5; + } + assert_eq!(widen_step_levels(&low, 1.0), vec![1.4]); + // No step with enough lines: the median over all of them, as before. + let few = wear_eye_with_widen_steps(&[(25, 1.2), (25, 1.3), (25, 1.4)]); + assert!(widen_step_levels(&few, 1.0).is_empty()); + assert_eq!(widen_level(&few, 1.0), (1.3, 75)); + assert!(wear_report(&[few.clone(), few]).1[0].contains("widen by step none,")); + // Widen frames more than WIDEN_STEP_GAP_S apart are two steps even with nothing between them. + let mut gap = samples(Label::Widen, 60, 1.2, 2.4, -14.0, 252.0, true); + gap.extend(samples(Label::Widen, 60, 1.5, 2.4, -14.0, 252.0, true)); + for (i, x) in gap.iter_mut().enumerate() { + x.t = i as f64 / 90.0 + if i >= 60 { 0.6 } else { 0.0 }; + } + assert_eq!(widen_step_levels(&gap, 1.0), vec![1.2, 1.5]); + // One widen step: its median, as before. + let one = wear_eye_with_widen_steps(&[(198, 1.25)]); + assert_eq!(widen_level(&one, 1.0), (1.25, 198)); + } + /// `n` widen frames whose usual band has no line near normal (skin NaN, as when the line was pushed against the /// band's edge) and whose higher band has the line at `skin2` (+ a little jitter), inlier share `frac2`. fn widen_high(n: usize, skin2: f64, frac2: f64) -> Vec { From 658bf155d7ebc80395ae5a5c8b22ebf88052dc71 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 17:01:58 +0900 Subject: [PATCH 43/65] =?UTF-8?q?eyecam:=20=E8=A6=8B=E9=96=8B=E3=81=8D?= =?UTF-8?q?=E3=81=AE=E9=AB=98=E3=81=95=E3=81=AE=E3=82=B3=E3=83=A1=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=AE=E3=80=81=E4=B8=8A=E3=82=92=E8=A6=8B=E3=81=9F?= =?UTF-8?q?=E3=81=A8=E3=81=8D=E3=81=AE=E6=95=B0=E5=AD=97=E3=82=92=E6=84=9F?= =?UTF-8?q?=E5=BA=A6=E3=81=94=E3=81=A8=E3=81=AB=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前のコミットの「上を見たとき 5 / 9 % -> 7 / 12 %」は感度の違う数字を比べていた。 正しくは感度 0.5 で 0 / 1 % -> 7 / 12 %、感度 1 で 5 / 9 % -> 8 / 15 %(左 / 右、0.3 以上)。コメントだけの変更 Co-Authored-By: Claude Opus 5.5 --- tools/eyecam/src/live.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index 7106bdc..7e470ae 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -2392,8 +2392,8 @@ pub fn widen_step_levels(s: &[Sample], b_n: f64) -> Vec { /// of them gave a step of 0.52 that the other two steps rose only 0.4 / 0.3 of. Replayed live and through frameeyeosc /// with that wearer's settings, widening went out at 0.5 or more in 36 % / 39 % of the widen frames (both eyes, widen /// sensitivity 0.5 / 1). With the lowest step's median the step is 0.25 (the right eye's 0.51 -> 0.43) and 91 % at -/// either; nothing widened (0.3 or more) in the normal and squint steps, looking up 7 / 12 % (5 / 9 % before), looking -/// down none at sensitivity 0.5 but 20 % at 1. On the developer's 27 wear calibrations the step stays the same or gets +/// either; nothing widened (0.3 or more) in the normal and squint steps, looking up 7 / 12 % at sensitivity 0.5 (0 / +/// 1 % before) and 8 / 15 % at 1 (5 / 9 %), looking down none at 0.5 but 20 % at 1 (none before). On the developer's 27 wear calibrations the step stays the same or gets /// smaller, by 0.011 in the median and 0.055 at most, and nothing else in them changes. fn widen_level(s: &[Sample], b_n: f64) -> (f64, usize) { let (all, n) = med_of(s, |x| widen_line(x, b_n)); From af9ba7566c171e7fccdd035cb932cdfd191a37fa Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 17:02:28 +0900 Subject: [PATCH 44/65] =?UTF-8?q?eyecam:=20=E8=A6=8B=E9=96=8B=E3=81=8D?= =?UTF-8?q?=E3=81=AE=E9=AB=98=E3=81=95=E3=81=AE=E3=82=B3=E3=83=A1=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=82=92=E6=8A=98=E3=82=8A=E8=BF=94=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tools/eyecam/src/live.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/eyecam/src/live.rs b/tools/eyecam/src/live.rs index 7e470ae..ba36136 100644 --- a/tools/eyecam/src/live.rs +++ b/tools/eyecam/src/live.rs @@ -2393,8 +2393,9 @@ pub fn widen_step_levels(s: &[Sample], b_n: f64) -> Vec { /// with that wearer's settings, widening went out at 0.5 or more in 36 % / 39 % of the widen frames (both eyes, widen /// sensitivity 0.5 / 1). With the lowest step's median the step is 0.25 (the right eye's 0.51 -> 0.43) and 91 % at /// either; nothing widened (0.3 or more) in the normal and squint steps, looking up 7 / 12 % at sensitivity 0.5 (0 / -/// 1 % before) and 8 / 15 % at 1 (5 / 9 %), looking down none at 0.5 but 20 % at 1 (none before). On the developer's 27 wear calibrations the step stays the same or gets -/// smaller, by 0.011 in the median and 0.055 at most, and nothing else in them changes. +/// 1 % before) and 8 / 15 % at 1 (5 / 9 %), looking down none at 0.5 but 20 % at 1 (none before). On the developer's +/// 27 wear calibrations the step stays the same or gets smaller, by 0.011 in the median and 0.055 at most, and nothing +/// else in them changes. fn widen_level(s: &[Sample], b_n: f64) -> (f64, usize) { let (all, n) = med_of(s, |x| widen_line(x, b_n)); let lowest = widen_step_levels(s, b_n).into_iter().fold(f64::INFINITY, f64::min); From 3e799320730d97199d5dc532b913616b04567c19 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 17:09:34 +0900 Subject: [PATCH 45/65] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B1=A5=E6=AD=B4:=20U?= =?UTF-8?q?nreleased=20=E3=81=AE=E7=AF=80=E3=82=92=E4=BD=9C=E3=82=8A?= =?UTF-8?q?=E3=80=81=E3=81=8B=E3=81=B6=E3=82=8A=E6=A0=A1=E6=AD=A3=E3=81=AE?= =?UTF-8?q?=E8=A6=8B=E9=96=8B=E3=81=8D=E3=81=AE=E9=AB=98=E3=81=95=E3=82=92?= =?UTF-8?q?=E6=AE=B5=E3=81=94=E3=81=A8=E3=81=AE=E4=B8=AD=E5=A4=AE=E5=80=A4?= =?UTF-8?q?=E3=81=AE=E4=B8=80=E7=95=AA=E4=BD=8E=E3=81=84=E3=82=82=E3=81=AE?= =?UTF-8?q?=E3=81=AB=E3=81=97=E3=81=9F=E4=BB=B6=E3=82=92=E6=97=A5=E8=8B=B1?= =?UTF-8?q?=E3=81=A7=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 4 ++++ CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 33f227b..1f66b94 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -1,5 +1,9 @@ # 更新履歴 +## Unreleased + +- eyecam、見開き: かぶり校正の見開きの高さを、見開きの段ぜんぶの中央値ではなく、段ごとの中央値のうち一番低いものにしました(上まぶたの線が取れたフレームが 30 以上ある段だけ)。ある段だけ上まぶたが他よりかなり高く読まれると、見開きの幅がほかの段の見開きには大きすぎて、見開きが届きにくくなっていました。そういう録画(度付きインサート)では左目の幅が 0.52 から 0.25 になり、見開きの段で 0.5 以上が送られたのは 36〜39 % から 91 % になり、普段と細めの段で見開くこともありませんでした。開発者のかぶり校正 27 回では、幅は同じか少し小さくなるだけでした(中央値で 0.011)。`calib_result.json` に目ごとの `widen_step_levels`(見開きの段ごとの中央値)が増えました。効かせるには「目のカメラの校正(18秒)」をやり直してください + ## 0.8.2-beta.2 (2026-10-24) SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。度付きインサートを使っているときも、目のカメラが上まぶたを見つけやすくなるはずです。見開きが出るようになり、開いた目が細めに読まれることも減ります。 diff --git a/CHANGELOG.md b/CHANGELOG.md index e73536b..365d86e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- eyecam, widening: the wear calibration's widen level is now the lowest of the widen steps' own medians (only steps with 30 or more frames with the lid line), not the median over all of them. When one widen step read the upper lid much higher than the others, the widen step came out too large for the widening the others reached, and widening was hard to reach: on one such recording (with prescription inserts) the left eye's step went from 0.52 to 0.25, and widening went out at 0.5 or more in 91 % of the widen frames instead of 36-39 %, while the normal and squint steps stayed unwidened; on the developer's 27 wear calibrations the step stayed the same or got slightly smaller (by 0.011 in the median). `calib_result.json` now also has each eye's `widen_step_levels` (each widen step's median). Run "Calibrate the cameras (18 s)" again for it to take effect. + ## 0.8.2-beta.2 (2026-10-24) The gaze no longer stays frozen when SteamOS keeps reading one open eye as closed: after a second, the other eye moves the gaze (#32). With "Move eyes separately" on, the new "Own gaze per eye" (Gaze › Reach) lets each eye's own sideways gaze through, so looking inward with both eyes can show. The eye cameras no longer lose a pupil that suddenly looks larger, the camera eyelids no longer take over for good after many blinks on SteamOS 0.4.x, and a new user calibration now sets the squint at the same depth whatever the wear. With prescription inserts, the eye cameras should find the upper eyelid more reliably, so widening can show and an open eye is less often read as a squint. From 319bf9e8385a390b4d82000752f39ec4f1bb32b4 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 17:18:59 +0900 Subject: [PATCH 46/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E3=80=8C?= =?UTF-8?q?=E7=B4=B0=E3=82=81=E3=81=AE=E5=AD=A6=E7=BF=92=E7=94=A8=E3=81=AB?= =?UTF-8?q?=E6=92=AE=E3=82=8B=E3=80=8D=E3=81=AE=E9=80=81=E3=82=8A=E6=96=B9?= =?UTF-8?q?=E3=82=92=20Google=20=E3=83=89=E3=83=A9=E3=82=A4=E3=83=96?= =?UTF-8?q?=E3=81=AA=E3=81=A9=E3=81=AE=E5=85=B1=E6=9C=89=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=AF=E3=81=AB=E3=80=81=E5=B7=A6=E5=8F=B3=E3=82=92=E8=A6=8B?= =?UTF-8?q?=E3=82=8B=E6=AE=B5=E3=82=92=E3=80=8C=E5=B7=A6=E3=82=92=E8=A6=8B?= =?UTF-8?q?=E3=81=A6=20/=20=E5=8F=B3=E3=82=92=E8=A6=8B=E3=81=A6=E3=80=8D?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 送り方の注意は「公開の場所に上げない」を残したまま、「Google ドライブなどに置いて、共有リンクを開発者に非公開で送ってね」にする(EN も)。 左右を見る段の文を短くする(説明画面の「左・右は自分から見た向き」はそのまま)。README・panel/README・更新履歴とテストも合わせ、 --fake-contrib に左を見る段の running-left を足す。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 2 +- CHANGELOG.md | 2 +- README.md | 2 +- panel/README.md | 6 +++--- panel/src/i18n.cpp | 16 ++++++++-------- panel/src/i18n.h | 3 ++- panel/src/main.cpp | 18 ++++++++++-------- panel/src/panel.cpp | 2 +- panel/tests/contrib_test.cpp | 4 ++-- 9 files changed, 29 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 68de0dc..64cec3e 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -4,7 +4,7 @@ パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。 -- 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります +- 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(Google ドライブなどの共有リンクを非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります - 録画は `~/eyecam/contrib/` にたまります。学習に使えるものだけを移します: `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろい、両目のフレームがあり、目ごとの raw がちょうど `frames.csv` のその目の枚数ぶんあり(ディスクがいっぱいで途中で切れたものをはじく)、フレームの 9 割以上に Valve の目のトラッキングの値がある。CSV とファイルの大きさだけで確かめ、まとめるときにも確かめ直します。使えない・途中で止まったものはわけと一緒に `aborted/` に入れて、撮り直してもらいます。隣には、いちばん新しい校正結果・ユーザー校正(`calib.json`)・eyecam の設定の写しを置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません - [撮影をはじめる]は、本体が動いていて目のデータが来ているときだけ押せます(ラベルに要る Valve の値は、本体が頼んでいるあいだしか来ないため)。押せないときはそう出します - 終わりの画面([録ったもの]からも)に、本数と合計の大きさ、フォルダ、送り方、[1 つのファイルにまとめる](`zstd -6` で `.tar.zst`。Frame の目の映像で 0.58 倍だった。7-Zip などで開ける。zstd が無ければ `.tar`。空き容量を確かめてから、低い優先度で裏でまとめる。元の録画は残す)、[録画を消す](確認つき)を出します diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed4ace..04647ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ New in the panel: "Record for squint training" (Advanced › Debug tools, once t 日本語: パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。 -- "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (privately, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). +- "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (a private share link from Google Drive or similar, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). - The recordings gather in `~/eyecam/contrib/`, each moved there once it can be used for training: `eye_L.raw`, `eye_R.raw`, `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` there, frames of both eyes, each raw file exactly as many frames as `frames.csv` lists for that eye (one cut short when the disk ran full is caught), and Valve's tracking values near at least 90 % of the frames; checked from the CSV files and file sizes alone, again when packing. One that can't be used, or stopped early, goes to `aborted/` with why, to be recorded again. Beside each go copies of the newest calibration result, the user calibration (`calib.json`) and eyecam's settings then. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. - "Start recording" waits for frameeyeosc to run with eye data coming in (Valve's values, which the labels need, only come while it asks for them), and says so. - The end's screen (also from "Recordings") shows how many there are and their size, the folder, how to send it, "Make one file" (a `.tar.zst` through `zstd -6`, measured on the Frame's eye video at 0.58 of the size, which 7-Zip and the like open; a `.tar` without zstd; packed in the background at a low priority after checking the free space; the recordings stay) and "Delete the recordings" (after a confirmation). diff --git a/README.md b/README.md index 6444b41..f5549c3 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ To go back, turn "Get test versions" off. Your test version stays installed unti - Eye fit: one button that fits straight ahead, the up range and the eyelids in about 20 seconds (see [Eye fit](#eye-fit)), "Re-center only" (the one dot in the middle), what to fit by itself when you put the headset on ("When put on": nothing or re-center), the result with "Reset", and the values by hand under "Fine-tune" (while it is folded, a box says how fitting works). - Eyelids: four pages under a switch at the top (the first time "Look", then the one you opened last, also after the panel restarts). "Look": where the eyelids come from now (the eye cameras, one eye camera and Valve's values, or Valve's values; the cameras are switched on the Basic tab), "Widening" On / Off (`widen`; Off: no eyelid goes above relaxed open, with the eye cameras or without, while blinks and squint stay; the slider below is greyed meanwhile), "How easily widening shows" while the eye cameras are in use (one slider from Dull to Sensitive: for eyes on the cameras it sets their widening sensitivity; for an eye on Valve's values it picks Off / Less / Normal / More (`lid_widen`) for eyes with an eye fit), "Half-shut check (AI)" On / Off while the eye cameras are in use (eyecam's eyelid veto, off by default: a small AI double-checks an open eye read as half shut, for example looking to the side, and keeps it open; it may also open up a light squint; experimental, trained on the developer's eyes only), eyelid smoothing (light / medium / strong), and how strongly both lids are synced (`lid_sync`). "Blinks & squint": making blinks visible (hold time), blinking both eyes together (`blink_sync_below`), and, while the eye cameras are in use, the lowest eyelid when narrowed (`camera_lid_floor`; it takes effect while an eye is on the cameras). "Fine-tune": the auto calibration and its learned values, per-eye scales, the four openness marks drawn over each eye's live openness (blink and open wide to set them; 3 and 4 greyed for fitted eyes and eyes on the cameras), "Treat nearly open as open" (`lid_open_snap`), and the two smoothing values. "Brows": "Widen raises brows" On / Off (`brow_from_widen`, off at first; the cameras don't see brows, so the avatar's brows follow the widening, also with "Widening" Off) and, while it is on, "Brow raise strength" (`brow_strength`, Weak to Strong). - Eye cameras: shown while eyecam runs. Before the setup has begun, what the cameras add and "Get started (about 2 min)", which opens the setup checklist (see [Eye cameras](#eye-cameras)); once set up, what drives the eyelids now (switched on or off on the Basic tab) with "Relearn my eyes" under it (learns your relaxed eyes again in about 30 s, keeping the calibration; the eyelids, widening and squint keep working from the old values meanwhile, then move over to the new ones), "Calibrate the cameras (18 s)" for when something feels off, the optional "User calibration (once)" for squints, and what to do when. -- Advanced: four pages under a switch at the top, "Version", "Having trouble", "Debug tools" and "Files" (the first time "Version", then the one you opened last, also after the panel restarts). "Version": the version with checking for and installing updates (and the automatic check on or off) and "Version history" (each version's summary and changes, newest first; one opens at a time, and the thumbstick or the up / down chevrons on the right scroll). "Having trouble": the Diagnostics page to send as a screenshot when stuck (see [Troubleshooting](#troubleshooting)), and the recent records of calibrations and eye fits (see below). "Debug tools": showing gaze dots and their distance, recording the eye log (see below), and while eyecam runs the "Developer" eye-camera recording for tuning the eye processing, which keeps the eye video in `~/eyecam/rec_…/` (about 2.5 GB each time; see [panel/README.md](panel/README.md)), and, once the eye cameras are set up, "Record for squint training": an optional one-press recording of your eyes for the developer (about 1.5 minutes: closing, squinting, widening and looking around, about 2.7 GB each), to train the "Half-shut check (AI)" on more people's eyes. It asks for your consent and whether you wear glasses or prescription inserts the first time, keeps the recordings in `~/eyecam/contrib/` until you send them privately and delete them, and can pack them into one file. "Files": file locations, frameeyeosc's PID, options locked by the command line. +- Advanced: four pages under a switch at the top, "Version", "Having trouble", "Debug tools" and "Files" (the first time "Version", then the one you opened last, also after the panel restarts). "Version": the version with checking for and installing updates (and the automatic check on or off) and "Version history" (each version's summary and changes, newest first; one opens at a time, and the thumbstick or the up / down chevrons on the right scroll). "Having trouble": the Diagnostics page to send as a screenshot when stuck (see [Troubleshooting](#troubleshooting)), and the recent records of calibrations and eye fits (see below). "Debug tools": showing gaze dots and their distance, recording the eye log (see below), and while eyecam runs the "Developer" eye-camera recording for tuning the eye processing, which keeps the eye video in `~/eyecam/rec_…/` (about 2.5 GB each time; see [panel/README.md](panel/README.md)), and, once the eye cameras are set up, "Record for squint training": an optional one-press recording of your eyes for the developer (about 1.5 minutes: closing, squinting, widening and looking around, about 2.7 GB each), to train the "Half-shut check (AI)" on more people's eyes. It asks for your consent and whether you wear glasses or prescription inserts the first time, keeps the recordings in `~/eyecam/contrib/` until you send them privately (a share link from Google Drive or similar) and delete them, and can pack them into one file. "Files": file locations, frameeyeosc's PID, options locked by the command line. - Records: every eye-camera calibration started from the panel and every eye fit (also the re-center run by itself when the headset is put on) leaves a small folder in `~/.local/state/frameeyeosc/reports/` (the newest 10 are kept): `summary.json` (what ran, how it ended and why, the versions, the diagnostic code, the conditions: dashboard open or closed, camera and eye-tracker rates, how long since the headset was put on), `status.jsonl` (frameeyeosc's and eyecam's status once a second while it ran), `logs.txt` (frameeyeosc's, the panel's and eyecam's journal and Valve's eye-tracker log from 15 seconds before it to its end, in time order, at most 300 KB), for calibrations `calib_result.json` (its numbers, never eye images) and `report.txt`. "Having trouble" lists the newest three ("All records" lists them all); "View" opens one: the reason, what happened in order, and the files. A failed fit or calibration says its log was saved, with "View record". Over SSH, `.local/bin/frameeyeosc-panel --report latest` prints the newest record's `report.txt` (`--report list` lists them, `--report ` prints one); it works without SteamVR. Nothing is sent anywhere. The panel writes `config.json` and reads the status file. To pick its default language, it also reads the `language` line of Steam's `~/.steam/registry.vdf` once at startup (read only). For updates it runs `~/.local/share/frameeyeosc/frame-update.sh` (see above). Closing it, quitting it, or not installing it doesn't stop frameeyeosc. While it isn't open on the dashboard it draws nothing. Besides running the update check, the only thing it reads then is the update state file (`~/.cache/frameeyeosc/update-state.json`), about twice a second. The exceptions are an eye fit (it then also reads the status file and shows the dot, with the dashboard open or closed, until the fit is over) and the debug gaze dots while they're switched on (it then listens on their socket and moves the dots about 90 times a second while samples arrive, and reads the status file and checks `config.json` for changes 10 times a second), and "When put on" while the gaze is fitted (it then reads the status file every 0.5 s while the dashboard is closed, to notice the headset being put on). While the eye preview is open it listens on the gaze dots' socket (without showing the dots) and redraws about 30 times a second; with its camera picture on, it asks eyecam for the pictures every second and reads them. Its "Start with SteamVR" switch enables or disables its systemd user unit (`frameeyeosc-panel.service`). Build notes and debugging options are in [panel/README.md](panel/README.md) (Japanese). diff --git a/panel/README.md b/panel/README.md index 1f18523..f0012e8 100644 --- a/panel/README.md +++ b/panel/README.md @@ -243,7 +243,7 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - `--report-make 種類 始め 終わり 結果 理由`(確かめるためのもの): その時間のジャーナルと Valve のログから、パネルが走り終わったあとに書くのと同じ記録を `--reports-dir` に書き、フォルダを出す(例: `--report-make fit "2026-10-06 20:45:09" "2026-10-06 20:45:17" failed "…"`。条件と診断コードは空) - 更新履歴は `--history` で開き、`--history-open 版` でその版の行を開き、`--history-scroll px` でスクロールして描く(どちらも `--history` を含む。スクロールは中身の範囲に収める)。変更履歴は上の場所から探す。`--changelog-dir DIR` でそのフォルダだけを見る(無いフォルダなら「見つかりません」の見た目)。`--fake` とも一緒に使える - `--click X,Y`(何回でも)は、描く前にその座標を押したことにする。当たり判定と設定ファイルの書き込みをヘッドセットなしで確かめる用(`--fake` とは一緒に使えない。`--config` の設定ファイルを本当に書き換えるので、試すときは別の場所を指定する) -- 「細めの学習用に撮る」(下)は `--fake-contrib 状態` で、作り物のフォルダ(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意)・`deleted`(消したあとの同意)・`eyewear`(眼鏡・インサートの質問)・`ready`(撮る前。これまで 3 本)・`noeyedata`(本体に目のデータが来ていない)・`nospace`(空きが足りない)・`running`(録画中: 2 回目の細め)・`failed`(途中で止まった)・`incomplete`(ファイルが足りなかった)・`novideo`(右目の映像がなかった)・`truncated`(raw が途中で切れていた)・`valve`(Valve の値が足りなかった)・`done`(撮れた: 4 本)・`list`([録ったもの]。1 本はファイルが足りずにまとめから外れた)・`packing`(まとめている 45 %)・`packed`(まとめた)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」の行を描く(`none` で 1 本目の前)。何も読み書きしない +- 「細めの学習用に撮る」(下)は `--fake-contrib 状態` で、作り物のフォルダ(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意)・`deleted`(消したあとの同意)・`eyewear`(眼鏡・インサートの質問)・`ready`(撮る前。これまで 3 本)・`noeyedata`(本体に目のデータが来ていない)・`nospace`(空きが足りない)・`running`(録画中: 2 回目の細め)・`running-left`(録画中: 左を見る段)・`failed`(途中で止まった)・`incomplete`(ファイルが足りなかった)・`novideo`(右目の映像がなかった)・`truncated`(raw が途中で切れていた)・`valve`(Valve の値が足りなかった)・`done`(撮れた: 4 本)・`list`([録ったもの]。1 本はファイルが足りずにまとめから外れた)・`packing`(まとめている 45 %)・`packed`(まとめた)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」の行を描く(`none` で 1 本目の前)。何も読み書きしない - 目の撮影タブ(下)は `--fake-eyecam waiting|idle|confirm|searching|recording:ラベル|error` と `--tab eyecam` で描く(`confirm` は idle で光の注意を開いたところ。`recording:ラベル:unlocked` でカメラが目を見失ったとき=「HMD をかぶってください」、`recording:ラベル:nolight` で光なしのプロトコル=「光なし」。`:pupil=0.97,0.04` で目ごとの瞳の見え具合(`null` も可。無ければ古い eyecam-rec)、`:failed=L|R|LR|mixed`(`:prov`)で片目だけ失敗した校正や両目の失敗のメッセージ。`:page=user:lookdown=L|R|LR` で、下を見たときの補正をその目(LR は両目)だけ標準の値にして通ったユーザー校正のカード、`:lookprev=L|R|LR` で前の値のままにして通ったカード(`lookdown=R:lookprev=L` で目ごとに違うとき)。`:search=not_worn|no_video|one_eye` で映像が見つからない理由(`status.json` の `search`。`not_worn` は近接センサー 12)。ラベルは lead_in・normal・widen・close・squint・look_up・look_down・bright・dark・end。recording は eyecam の `protocol_widen.txt`(17 ステップ・83 秒。`nolight` なら `protocol_widen_nolight.txt` の 14 ステップ)でそのラベルが最初に出るステップになり、その手順をステップのチップにも使う(ファイルは読まず、同じ手順を main.cpp に持っている)。error は失敗した返事も出す)。`--eyecam-fill-png PATH`(`--eyecam-fill bright|dark`、`--language`)で全面の光の画像。`--eyecam-dir DIR` で eyecam-rec のフォルダ(`status.json` と `ctl.sock`)を別の場所にする。`--fake` なしの `--dump-png` はそこを読み、`--click` で[撮影開始]→[光ありで始める]/[光なしで始める]や[中止]を押すとそのソケットに本当に送って返事を待つ(試すときは一時フォルダに偽の `status.json` とソケットを置く。本物の `/run/user/1000/eyecam/` には何も作らない) - `--probe` は Background 型でつなぐだけで、オーバーレイも Vulkan も作らない。`FindOverlay`・名前・幅・閉じるボタン・表示中か・`GetOverlayTextureSize` を出す - `contrib/icons/frameeyeosc-panel-{48,128,256}.png` は `--thumbnail-png` で書き出したもの(ダッシュボードのサムネイルと同じ絵) @@ -266,10 +266,10 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - **入り口**: 「詳細」›「動作確認」の「目の撮影」の下の「細めの学習用に撮る」の[撮る]と[録ったもの](上の表)。目の撮影と同じく eyecam-rec が動いているあいだだけ出て、押せるのは目のカメラの準備が済んでから(`eyecam::setUp`)。「開発用」のバッジは付けない(使う人向け)。まぶたタブの「半目の読み直し(AI)」の行は使う人の設定なので、録画の入り口はそこに置かない - **シート**: 詳細タブの中身の代わりに出る(タブの列と左の列はそのまま。どのタブ・小タブを押しても閉じる。ダッシュボードを閉じても開いたまま)。上に「細めの学習用に撮る」と「半目の読み直し(AI)を、いろんな目で賢くするための撮影だよ」、右に × の[閉じる]。[撮る]で開くたびに、前の終わり・失敗の画面は忘れる。画面は `contrib::screen` が決める: - - **同意**(`info.txt` に同意が無い・同意文の版〔`contrib::kConsentVersion`、今は 1〕が古い・眼鏡の答えが無いとき): 「「半目の読み直し(AI)」が細めや半目をもっと正しく見分けられるように、学習用の目の映像を録るよ。協力するかどうかは自由です。」、見出しつきで「録るもの」(目のカメラの映像を 1 回約 1 分半〔1 本 約 2.7 GB〕と、そのときの校正の数字と設定。送るまでは Frame の中だけ)・「使いみち」(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く)・「消してほしいとき」(言えば消す。もう配った AI に入った分は取り出せない)、警告色の枠で送り方の注意「目の映像は GitHub の issue や公開の場所に上げないでね。開発者に非公開で送ってね(送り方は開発者が伝えます)。」(連絡先はアプリに入れない)、灰色で「画面が白や黒に光ることはないよ(光なしで撮るよ)。」(光の注意は要らない)。[やめる]と[同意して次へ]。消したあとは上に緑で「録画を消したよ」 + - **同意**(`info.txt` に同意が無い・同意文の版〔`contrib::kConsentVersion`、今は 1〕が古い・眼鏡の答えが無いとき): 「「半目の読み直し(AI)」が細めや半目をもっと正しく見分けられるように、学習用の目の映像を録るよ。協力するかどうかは自由です。」、見出しつきで「録るもの」(目のカメラの映像を 1 回約 1 分半〔1 本 約 2.7 GB〕と、そのときの校正の数字と設定。送るまでは Frame の中だけ)・「使いみち」(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く)・「消してほしいとき」(言えば消す。もう配った AI に入った分は取り出せない)、警告色の枠で送り方の注意「目の映像は GitHub の issue や公開の場所に上げないでね。Google ドライブなどに置いて、共有リンクを開発者に非公開で送ってね。」(連絡先はアプリに入れない)、灰色で「画面が白や黒に光ることはないよ(光なしで撮るよ)。」(光の注意は要らない)。[やめる]と[同意して次へ]。消したあとは上に緑で「録画を消したよ」 - **眼鏡・インサート**: 「HMD の中で、眼鏡や度付きインサートを使ってる?」「学習のときに分けて見るためだけに使うよ。録画ごとに info.txt に書くよ」、同じ重さの 4 つ[使ってない][眼鏡][度付きインサート][答えない]と[戻る]。答えたら、そのとき同意と答えを `info.txt` に書く(フォルダが無ければ作る) - **撮る前**: 「細めの学習用の撮影(約 1 分半)」と点つきで「最初の 10 秒は、普通に開けて正面を見て待ってね」「ビープと画面の指示に合わせて、閉じる・普通・細める・普通・見開くを 3 回、そのあと上・下・左・右を見るよ」「細めるは、まぶしいときみたいに目を細める(閉じない)」「左・右は自分から見た向き。顔は正面のまま、目だけ動かしてね」「画面が白や黒に光ることはないよ」、録ったものがあれば灰色で「これまで 3 本・合計 8.2 GB」。ボタンの上に灰色で「空き容量 23.2 GB(1 本 約 2.7 GB)」(足りなければ赤で「空きが足りないよ(約 3.8 GB 要る)」、本体が止まっているか目のデータが来ていなければ赤で「目の送信が動いていて、目のデータが来ているときに撮れるよ(Valve の値も一緒に録るため)」、eyecam-rec がほかのことをしていれば「目のカメラがほかのことをしているあいだは待ってね」)、[撮影をはじめる(約 1 分半)]。押せるのは同意が済んで、eyecam-rec が待機中(かエラー)・コマンドの返事待ちでない・まとめていない・1 本ぶん(目のカメラの毎秒の枚数から。分からなければ 90)と 1 GiB が空いている・本体が動いていて目のデータが来ている(状態ファイルの `running` と `tracking`。Valve のサンプルは本体が頼んでいるあいだしか来ないので、無いまま撮ると 93 秒あとに「Valve の値が 0 %」で aborted になる)とき(`contrib::startAllowed`) - - **録画中**: eyecam-rec が探している・録っているあいだは目の撮影の画面(`drawRun`)のまま、見出しだけ「細めの学習用に撮る」(その前後のつなぎ目は見出しと「送っています…」だけ)。ステップのチップは `protocol_contrib.txt` から(23 ステップ)。左右を見る段は「左の耳の方を見て」「右の耳の方を見て」(チップは「左を見る」「右を見る」)。シートを閉じていても、詳細タブの録画の画面は同じ見出しになる + - **録画中**: eyecam-rec が探している・録っているあいだは目の撮影の画面(`drawRun`)のまま、見出しだけ「細めの学習用に撮る」(その前後のつなぎ目は見出しと「送っています…」だけ)。ステップのチップは `protocol_contrib.txt` から(23 ステップ)。左右を見る段は「左を見て」「右を見て」(チップは「左を見る」「右を見る」)。シートを閉じていても、詳細タブの録画の画面は同じ見出しになる - **録れなかった**: 赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった・ファイルが足りなかった〔そのファイル名〕・片目か両目の映像が 1 枚もなかった〔「右目の映像がなかったよ。目のカメラの準備を確かめて」〕・ファイルが途中で切れていた〔「空き容量を確かめて」〕・Valve の目のトラッキングの値が足りなかった〔フレームの何 % にあったか。「SteamVR の目のトラッキングが動いてるか確かめて」〕。どれも「もう一度撮ってね」)、録画のフォルダがあれば灰色で「この録画は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度撮る] - **終わり**(撮れたあと、または[録ったもの]): 撮れたあとは緑のチェックと「撮れたよ。ありがとう!」、[録ったもの]なら「録ったもの」。「これまで 4 本・合計 10.9 GB」、灰色で「場所: ~/eyecam/contrib/」、撮れたあとは「別の日や、かぶり直したあとにもう一度押してくれると、もっと助かるよ」、送り方の注意の枠。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.7 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(4 本・6.3 GB)」とその下にファイル名(それより本数が増えるまでは押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。その下に灰色で「まとめたファイルは .tar.zst(7-Zip などで開けるよ)」(zstd が無くて .tar になるときは出さない)。まとめるときに学習に使えなかった録画は赤で「rec_… は学習に使えない形だったので入れなかったよ。もう一度撮ってね」。下の左に[録画を消す]、右に[閉じる] - **消す確認**: 赤い枠のカードで「撮った録画を全部消す?」「~/eyecam/contrib/ を中身ごと(10.9 GB)消します。元に戻せないよ。開発者に送ったあとで消してね。」、[やめる](アクセント)と[消す]。消している間は「消しています…」、消したら同意の画面(同意も `info.txt` と一緒に消える) diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index fe6cd8f..99f8e70 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -592,8 +592,8 @@ UiText makeJapanese() { t.eyecamStepSquint = "目を細めて"; t.eyecamStepLookUp = "上を見て"; t.eyecamStepLookDown = "下を見て"; - t.eyecamStepLookLeft = "左の耳の方を見て"; - t.eyecamStepLookRight = "右の耳の方を見て"; + t.eyecamStepLookLeft = "左を見て"; + t.eyecamStepLookRight = "右を見て"; t.eyecamStepBright = "明るい画面を見て"; t.eyecamStepDark = "暗い画面を見て"; t.eyecamStepEnd = "おわり"; @@ -899,8 +899,8 @@ UiText makeJapanese() { t.contribRemoveTitle = "消してほしいとき"; t.contribRemove = "言ってくれれば消します。ただ、もう配った AI に入った学習の分は取り出せません。"; t.contribSendTitle = "送り方"; - t.contribSendNote = "目の映像は GitHub の issue や公開の場所に上げないでね。開発者に非公開で送ってね" - "(送り方は開発者が伝えます)。"; + t.contribSendNote = "目の映像は GitHub の issue や公開の場所に上げないでね。Google ドライブなどに置いて、" + "共有リンクを開発者に非公開で送ってね。"; t.contribNote = "画面が白や黒に光ることはないよ(光なしで撮るよ)。"; t.contribAgree = "同意して次へ"; t.contribCancel = "やめる"; @@ -1644,8 +1644,8 @@ UiText makeEnglish() { t.eyecamStepSquint = "Squint"; t.eyecamStepLookUp = "Look up"; t.eyecamStepLookDown = "Look down"; - t.eyecamStepLookLeft = "Look toward your left ear"; - t.eyecamStepLookRight = "Look toward your right ear"; + t.eyecamStepLookLeft = "Look left"; + t.eyecamStepLookRight = "Look right"; t.eyecamStepBright = "Look at the bright screen"; t.eyecamStepDark = "Look at the dark screen"; t.eyecamStepEnd = "Done"; @@ -1957,8 +1957,8 @@ UiText makeEnglish() { t.contribRemove = "Ask and it is deleted. What an AI already released has learned from it can't be taken back " "out."; t.contribSendTitle = "Sending it"; - t.contribSendNote = "Don't upload the eye video to a GitHub issue or anywhere public. Send it to the developer " - "privately (the developer will tell you how)."; + t.contribSendNote = "Don't upload the eye video to a GitHub issue or anywhere public. Put it on Google Drive or " + "similar and send the share link to the developer privately."; t.contribNote = "No white or black screens (recorded without light)."; t.contribAgree = "Agree and continue"; t.contribCancel = "Cancel"; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index b974ba8..25613c5 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -903,7 +903,8 @@ struct UiText { const char* contribRemoveTitle; ///< ...deleting it later const char* contribRemove; const char* contribSendTitle; ///< ...how to send it - const char* contribSendNote; ///< ...privately, never anywhere public (also on the end's screen) + const char* contribSendNote; ///< ...a private share link (Google Drive or similar), never anywhere public + ///< (also on the end's screen) const char* contribNote; ///< ...no light, how long const char* contribAgree; ///< ...its button const char* contribCancel; ///< ...and closing without it diff --git a/panel/src/main.cpp b/panel/src/main.cpp index b5ab228..6310992 100644 --- a/panel/src/main.cpp +++ b/panel/src/main.cpp @@ -386,7 +386,8 @@ void printUsage() { " --fake-password set|unset|unknown The setup's password check (default: set)\n" " --fake-contrib STATE \"Record for squint training\" open on a made-up screen (the eye cameras set up\n" " and idle unless --fake-eyecam says otherwise): consent, deleted, eyewear,\n" - " ready, nospace, noeyedata (frameeyeosc gets no eye data), running, failed,\n" + " ready, nospace, noeyedata (frameeyeosc gets no eye data), running (a squint),\n" + " running-left (looking left), failed,\n" " incomplete, novideo, truncated, valve (not usable: a file missing, an eye's video\n" " missing, a raw file cut short, Valve's values missing), done (just\n" " recorded), list (\"Recordings\"), packing, packed, delete, deleting (with --adv-page tools: its\n" @@ -463,7 +464,7 @@ bool parsePreviewSpec(const std::string& text, eye_preview::Source& source, bool /** --fake-contrib's screens (fakeContrib). */ const std::string kFakeContribStates[] = {"none", "consent", "deleted", "eyewear", "ready", "nospace", "running", "failed", "incomplete", "novideo", "truncated", "valve", - "noeyedata", "done", + "noeyedata", "running-left", "done", "list", "packing", "packed", "delete", "deleting"}; /** --fake-eyecam, taken apart. */ @@ -1685,9 +1686,9 @@ void fakeContrib(PanelModel& m, const std::string& state, bool eyecamGiven) { } c.totalBytes = contrib::recordedBytes(c.info); c.justDone = justDone; - if (state == "running") { + if (state == "running" || state == "running-left") { m.eyecam = fakeEyecam("recording:squint"); - // (protocol_contrib.txt's steps: the second squint, 35 s in) + // (protocol_contrib.txt's steps: the second squint, 35 s in; or looking left, 80 s in) eyecam::Status& s = m.eyecam.status; s.protocol = contrib::kProtocol; m.eyecam.protocol.name = s.protocol; @@ -1696,11 +1697,12 @@ void fakeContrib(PanelModel& m, const std::string& state, bool eyecamGiven) { "squint", "normal", "widen", "look_up", "look_down", "look_left", "normal", "look_right", "normal"}; s.stepCount = static_cast(m.eyecam.protocol.steps.size()); - s.stepIndex = 9; - s.stepLabel = "squint"; + const bool left = state == "running-left"; + s.stepIndex = left ? 19 : 9; + s.stepLabel = left ? "look_left" : "squint"; s.totalS = contrib::kProtocolSec + contrib::kTailSec; - s.elapsedS = 36.6; - s.stepRemainingS = 2.4; + s.elapsedS = left ? 79.2 : 36.6; + s.stepRemainingS = left ? 2.8 : 2.4; c.running = true; } if (state == "failed") { diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index 7267835..0bb8357 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -7075,7 +7075,7 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) y += size * 1.4; } }; - // How to send it, in a box with the warning's edge: privately, never anywhere public + // How to send it, in a box with the warning's edge: a private share link, never anywhere public const auto sendNote = [&](double size) { const double pad = 14; const std::vector lines = wrapText(pen, t.contribSendNote, size, false, width - pad * 2 - 30, 3); diff --git a/panel/tests/contrib_test.cpp b/panel/tests/contrib_test.cpp index f9512fd..8f64bf9 100644 --- a/panel/tests/contrib_test.cpp +++ b/panel/tests/contrib_test.cpp @@ -253,8 +253,8 @@ void testProtocolFile() { if (label != "lead_in") CHECK(eyecam::chipLabel(t, label) != label); } } - SAME(eyecam::instruction(uiText(Language::Ja), "look_left"), "左の耳の方を見て"); - SAME(eyecam::instruction(uiText(Language::En), "look_right"), "Look toward your right ear"); + SAME(eyecam::instruction(uiText(Language::Ja), "look_left"), "左を見て"); + SAME(eyecam::instruction(uiText(Language::En), "look_right"), "Look right"); } void testInfo() { From 03202936757a93c94bc3eca9ae4a6d038cbe89cf Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 17:21:37 +0900 Subject: [PATCH 47/65] =?UTF-8?q?0.8.2-beta.3:=20=E7=89=88=E3=82=92?= =?UTF-8?q?=E4=B8=8A=E3=81=92=E3=80=81=E6=9B=B4=E6=96=B0=E5=B1=A5=E6=AD=B4?= =?UTF-8?q?=E3=81=AE=E7=AF=80=E3=81=AE=E5=90=8D=E5=89=8D=E3=82=92=E5=A4=89?= =?UTF-8?q?=E3=81=88=E3=81=A6=E8=A6=8B=E9=96=8B=E3=81=8D=E3=81=AE=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=82=92=E8=A6=81=E7=B4=84=E3=81=AB=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 4 ++-- CHANGELOG.md | 6 +++--- Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 26f0ef5..6f2f955 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -1,8 +1,8 @@ # 更新履歴 -## Unreleased +## 0.8.2-beta.3 (2026-10-25) -パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。 +パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。また、目のカメラの校正で見開くたびに上まぶたの高さが違って読まれる人も、見開きが届きやすくなりました(更新したらカメラの校正をやり直してね)。 - 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(Google ドライブなどの共有リンクを非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります - 録画は `~/eyecam/contrib/` にたまります。学習に使えるものだけを移します: `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろい、両目のフレームがあり、目ごとの raw がちょうど `frames.csv` のその目の枚数ぶんあり(ディスクがいっぱいで途中で切れたものをはじく)、フレームの 9 割以上に Valve の目のトラッキングの値がある。CSV とファイルの大きさだけで確かめ、まとめるときにも確かめ直します。使えない・途中で止まったものはわけと一緒に `aborted/` に入れて、撮り直してもらいます。隣には、いちばん新しい校正結果・ユーザー校正(`calib.json`)・eyecam の設定の写しを置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c400c0..c837e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -## Unreleased +## 0.8.2-beta.3 (2026-10-25) -New in the panel: "Record for squint training" (Advanced › Debug tools, once the eye cameras are set up) records your eyes for the developer with one press, to train the "Half-shut check (AI)" on more people's eyes. It is entirely optional: it says first what is recorded and how it is used, and records only after you agree. +New in the panel: "Record for squint training" (Advanced › Debug tools, once the eye cameras are set up) records your eyes for the developer with one press, to train the "Half-shut check (AI)" on more people's eyes. It is entirely optional: it says first what is recorded and how it is used, and records only after you agree. Widening is also easier to reach for people whose upper lid reads at a different height in each widen of the eye-camera calibration (calibrate the cameras again after updating). -日本語: パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。 +日本語: パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。また、目のカメラの校正で見開くたびに上まぶたの高さが違って読まれる人も、見開きが届きやすくなりました(更新したらカメラの校正をやり直してね)。 - "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (a private share link from Google Drive or similar, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). - The recordings gather in `~/eyecam/contrib/`, each moved there once it can be used for training: `eye_L.raw`, `eye_R.raw`, `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` there, frames of both eyes, each raw file exactly as many frames as `frames.csv` lists for that eye (one cut short when the disk ran full is caught), and Valve's tracking values near at least 90 % of the frames; checked from the CSV files and file sizes alone, again when packing. One that can't be used, or stopped early, goes to `aborted/` with why, to be recorded again. Beside each go copies of the newest calibration result, the user calibration (`calib.json`) and eyecam's settings then. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. diff --git a/Cargo.lock b/Cargo.lock index 154d2a5..9a0e69b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "frameeyeosc" -version = "0.8.2-beta.2" +version = "0.8.2-beta.3" dependencies = [ "clap", "libc", diff --git a/Cargo.toml b/Cargo.toml index db429f2..bde561e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frameeyeosc" -version = "0.8.2-beta.2" +version = "0.8.2-beta.3" edition = "2024" license = "MIT" description = "Sends Steam Frame eye tracking (gaze and eyelids) to VRChat over OSC" From 59d969834b4fe19d01314db4effae065e281d658 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 17:42:03 +0900 Subject: [PATCH 48/65] =?UTF-8?q?eyecam:=20=E7=89=87=E6=96=B9=E3=81=AE?= =?UTF-8?q?=E3=82=AB=E3=83=A1=E3=83=A9=E3=81=AE=E3=82=B9=E3=83=AD=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=81=8C=204=20=E3=81=A4=E3=81=9D=E3=82=8D=E3=82=8F?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=83=AA=E3=83=B3=E3=82=B0=E3=81=A7=E3=81=AF?= =?UTF-8?q?=E3=83=AD=E3=83=83=E3=82=AF=E3=81=97=E3=81=AA=E3=81=84=EF=BC=88?= =?UTF-8?q?=E5=8F=B3=E7=9B=AE=E3=81=8C=2022.5=20fps=20=E3=81=AE=E3=81=BE?= =?UTF-8?q?=E3=81=BE=E6=A0=A1=E6=AD=A3=E3=83=BB=E9=8C=B2=E7=94=BB=E3=81=8C?= =?UTF-8?q?=E9=80=9A=E3=81=A3=E3=81=A6=E3=81=84=E3=81=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原因: HMD を外しているとき、写っていないスロットのところでリングをたどるのが止まり、部分的なリングになる。 0.7.9 の 4+4 のガードは 8 スロットそろったリングにしか効かず、部分的なリングはアドレスの区切りどおり (5 スロット [1, 0, 0, 0, 0] → 右目 1 スロット = 4 枚に 1 枚)か、区切りがなければ絵の似かたで両目に分けていた (左目のカメラの 4 スロットだけを [1, 0, 0, 0] などに分け、右目に左目の映像を送っていた)。 journal の 2026-10-16〜25 のロック 138 回のうち 17 回がこれで、どれも外しているか、かぶったその秒 - discover: 両方のカメラのスロットがあっても 4 つずつそろわなければ取らない(stopped_at few_slots、ログ「not taken」)。 区切りのない部分的なリングは片方のカメラだけとして扱い、絵では分けない(cameras_by_picture を消す) - 念のため: ロックしたあと、片方のカメラの枚数がもう片方の半分未満(0 ではない)の状態が 5 秒続いたら探し直す (ring::FrameBalance。待機中のライブと録画・校正の両方。meta.txt に unbalanced_at_t_raw)。 片方のカメラが止まったときは今までどおり - テスト: 写っていないスロットのある模擬カメラで取らないこと、実機で起きた形(5 / 6 スロット、左目だけの 4 スロット)、 FrameBalance の 5 秒と、やり直し・止まったカメラ・72 対 90 では動かないこと Co-Authored-By: Claude Opus 5.5 --- tools/eyecam/README.md | 10 +- tools/eyecam/src/bin/eyecam-rec.rs | 58 ++++++-- tools/eyecam/src/ring.rs | 213 +++++++++++++++++++++++------ 3 files changed, 226 insertions(+), 55 deletions(-) diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index 247258c..af6279a 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -186,7 +186,7 @@ uid 1000(steamos)前提で `/run/user/1000` は固定(`XDG_RUNTIME_DIR` | `pupil_l`, `pupil_r` | number / null | 目ごとの、直近 2 秒のフレームのうち瞳が見つかった割合(0〜1)。校正が「瞳が見えたフレーム」を数えるのと同じ数え方(閉じたまぶたのしわを除いた後)。ライブ処理がオフのときと、その目の映像が止まったときは `null`。**`locked` は映像が流れているという意味だけ**なので、校正の前に瞳が見えているかはこちらで見る。ふだん(目を開けて前を見ている)は 0.95 前後、目を閉じると 0。`calib wear` は普段の段(約 5.4 秒)のうち約 1 秒ぶん(90 fps で 90 フレーム、約 18%)以上で瞳が要る。`--fake` では 0.97 / 0.95 | | `cam_fps` | [number / null, number / null] | 目ごと `[左, 右]` の、カメラが届けている毎秒の枚数(直近 2 秒のフレームの、カメラの時刻で数える)。ロックしていてライブ処理がオンのときだけ。そうでないときと、その目の映像が 0.5 秒止まったときは `null`。Valve のアイトラッカーがカメラの速さを決める(ログ `~/.local/share/Steam/logs/eyetracking*.txt` の `CStereoAdspCams: Set framerate`)ので、ふつうは 90 だが 72・80・120 もあり、**ヘッドセットによっては 15**。eyecam はどの速さでも動く(下の「遅いカメラ」)。`--fake` では `--fake-camfps` の値(既定 90) | | `prox_min` | number | `--prox-min`(近接センサーがこれを超えて 1 秒たつと「かぶっている」。既定 20)。パネルの診断が `prox` と並べて出す | -| `search_detail` | object / null | 直近に目の映像を探したときに見えたもの(パネルの診断用)。探すたびに書き換え、ロックしたあとも残る。まだ一度も見ていなければ `null`。`candidates`(変化したメモリの中で絵に見えたフレームの数)、`refresh_hz`(そのうち選んだバッファの候補が 1 秒に何回書き換わったかの中央値。選んだバッファの候補が 2 つ未満なら 0)、`slots`(見つかったリングのスロット数。ふつう 8)、`both_eyes`(両目のスロットがそろった)、`stopped_at`(どこで止まったか: `""` 見つかった〔両目〕/ `no_candidates` 絵に見える候補が 2 つ未満 / `split_buffers` 候補は 2 つ以上あるが、いちばん多いバッファでも 2 つ未満(ほかのバッファに散らばっている)/ `not_refreshing` 1 秒に 3 回以上書き換わる候補が 2 つ未満 / `few_slots` リングのスロットが 2 つ未満 / `one_eye` 片目のスロットだけ)、`changed_blocks`(その前に変化した 64 KiB のブロックの数。0 ならバッファに何も書かれていない)、`unix`(見た時刻) | +| `search_detail` | object / null | 直近に目の映像を探したときに見えたもの(パネルの診断用)。探すたびに書き換え、ロックしたあとも残る。まだ一度も見ていなければ `null`。`candidates`(変化したメモリの中で絵に見えたフレームの数)、`refresh_hz`(そのうち選んだバッファの候補が 1 秒に何回書き換わったかの中央値。選んだバッファの候補が 2 つ未満なら 0)、`slots`(見つかったリングのスロット数。ふつう 8)、`both_eyes`(両目のスロットがそろった)、`stopped_at`(どこで止まったか: `""` 見つかった〔両目〕/ `no_candidates` 絵に見える候補が 2 つ未満 / `split_buffers` 候補は 2 つ以上あるが、いちばん多いバッファでも 2 つ未満(ほかのバッファに散らばっている)/ `not_refreshing` 1 秒に 3 回以上書き換わる候補が 2 つ未満 / `few_slots` リングのスロットが 2 つ未満か、両方のカメラのスロットはあるが 4 つずつそろっていない / `one_eye` 片目のスロットだけ)、`changed_blocks`(その前に変化した 64 KiB のブロックの数。0 ならバッファに何も書かれていない)、`unix`(見た時刻) | | `last_calib` | object / null | 直近の `calib wear`(この起動でしたもの、なければ `~/eyecam/calib_*/calib_result.json` のいちばん新しい wear のもの。どちらも無ければ `null`)。`time`(ローカル時刻 `YYYY-MM-DD HH:MM:SS`)、`ok`、`failed_eye`(`calib_failed_eye` と同じ。`failed_eye` の無い古い calib_result.json は、失敗なら `LR`)、`message` / `message_en`、目ごとの `[左, 右]` で `pupil_frames`(普段の段で瞳が見えたフレーム)・`normal_frames`(普段の段のフレーム)・`pupil_x` / `pupil_y`(普段の段の瞳の位置の中央値、px)・`window`(瞳を探した枠の左右 `[[左目の左, 左目の右], [右目の左, 右目の右]]`)・`fps`(その校正のときのカメラの毎秒の枚数)。無い値は `null`。途中で止めた校正もここに入る(`ok` false、`message` は `途中で止めた`) | | `last_error`, `last_error_en`, `last_error_unix` | string / string / number | 直近に `state` が `error` になったときの `message`(とその英語)と時刻(UNIX 秒)。`state` が戻っても残る。まだ無ければ `""` と 0 | | `lid_veto` | object | まぶたの拒否権(下の「まぶたの拒否権」)。`on`(settings.json の `lid_veto`。既定 false)、`runs_per_s`(CNN を回した回数、両目で直近 1 秒の毎秒)、`fired`(拒否権を出した回数)、`ms_mean` / `ms_max`(CNN 1 回の時間の平均と最大、ms)。`fired` と時間はライブ処理が始まってから数える。このフィールドがない古い eyecam-rec もある | @@ -1133,6 +1133,14 @@ sudo で動かすのは、自分でビルドしたもの、またはリリース 20% 以上が明るい・1 秒間更新が続く、の 2 条件がそろったときだけロックする(元は装着中も条件だったが、近接センサーが かぶっていても低い顔があるので外した)。「更新が続く」は 1 秒に 3 回以上書き換わること(片目 4 スロットなので、1 つのスロットは カメラの速さの 4 分の 1 で書き換わる。15 fps のカメラでは毎秒 3.75 回。元の 5 回では 15 fps のカメラでロックできないことがあった) +- 両目のスロットがそろっていても、**それぞれのカメラに 4 スロットずつないリングはロックしない**(`few_slots` のまま探し続ける)。 + HMD を外しているときは、写っていないスロットのところでリングをたどるのが止まり、5 スロット(右目 1・左目 4)や 6 スロットで + ロックしていた。右目は 4 枚に 1 枚しか読めず 22.5 fps のまま校正や録画が通ってしまう(2026-10-16〜25 の 138 回中 17 回。 + どれも外しているか、かぶったその秒)。アドレスの区切りがない部分的なリングは片方のカメラだけなので、絵が違って見えても両目には + 分けない(左目のカメラの 4 スロットを絵で 2 つに分けて、右目に左目の映像を送っていた)。 + 念のため、ロックしたあと片方のカメラの枚数がもう片方の半分未満(少しは来ている)の状態が 5 秒続いたら、探し直す + (ログ `one eye camera's frames came far slower …`。録画・校正中なら meta.txt に `unbalanced_at_t_raw`)。 + 片方のカメラが止まったときはこれに当たらず、今までどおりもう片方で続ける - 正確な先頭位置は eyecam 独自で、**スロットごと**に決める: ピクセルは黒くても 0 にならず(黒レベル約 4)毎フレーム変わる一方、 パディング列と行間のすき間は 0 のまま変わらない。その境目がぴったり合う位置を先頭にする(パディング削除がずれない/ヘッダが先頭の直前に来る) - ヘッダの先頭 8 バイトはカメラのタイムスタンプ(u64、CLOCK_MONOTONIC_RAW の ns。90 fps で 11.11 ms 間隔)。残り 56 バイトは今のところ 0。 diff --git a/tools/eyecam/src/bin/eyecam-rec.rs b/tools/eyecam/src/bin/eyecam-rec.rs index 3a80604..a674f8d 100644 --- a/tools/eyecam/src/bin/eyecam-rec.rs +++ b/tools/eyecam/src/bin/eyecam-rec.rs @@ -959,29 +959,55 @@ struct Pump { slots: SlotStates, buf: Vec, last_change: f64, + /// Frames per camera (before --swap), and whether one camera has been far slower than the other (ring::FrameBalance) + count: [u64; 2], + balance: ring::FrameBalance, + unbalanced: bool, } impl Pump { fn new(arena: &Arena, ring: Ring) -> Self { let slots = SlotStates::new(arena, &ring); - Self { ring, slots, buf: vec![0u8; HEADER_BYTES + FRAME_BYTES], last_change: now_raw() } + Self { + ring, + slots, + buf: vec![0u8; HEADER_BYTES + FRAME_BYTES], + last_change: now_raw(), + count: [0; 2], + balance: ring::FrameBalance::default(), + unbalanced: false, + } } - /// One pass over the slots. False once frames have stopped for LOST_AFTER. + /// One pass over the slots. False once frames have stopped for LOST_AFTER, or one camera's have come far slower + /// than the other's for ring::UNBALANCED_FOR_S (`unbalanced`). fn poll(&mut self, arena: &Arena, now: f64, swap: bool, live: &mut Live) -> bool { for k in 0..self.ring.off.len() { match self.slots.poll(arena, &self.ring, k, now, &mut self.buf) { SlotEvent::None => {} SlotEvent::Changed => self.last_change = now, SlotEvent::Frame(_) => { + self.count[self.ring.eye[k] as usize] += 1; live.frame(self.ring.eye[k] as usize ^ swap as usize, self.ring.upside_down(k), &self.buf, now) } } } - now - self.last_change <= LOST_AFTER + self.unbalanced = self.ring.both_eyes && self.balance.check(now, self.count); + now - self.last_change <= LOST_AFTER && !self.unbalanced } } +/// The log line for a ring left because one camera's frames came far slower than the other's (ring::FrameBalance). +fn unbalanced_line(ring: &Ring, balance: &ring::FrameBalance) -> String { + format!( + "one eye camera's frames came far slower than the other's for {} s (cameras {:?}: {:.1} / {:.1} fps)", + ring::UNBALANCED_FOR_S, + ring.eye, + balance.fps[0], + balance.fps[1] + ) +} + /// Wait for `start` / `calib` while holding the buffers, keeping the ring locked and feeding the live engine while /// live processing is on, and counting the times the headset is taken off (`off`, status.json headset_off_count). /// None on shutdown, or why the buffers had to be given up. @@ -1130,11 +1156,17 @@ fn idle( if live.on { if let Some(p) = pump.as_mut() { if !p.poll(&b.arenas[p.ring.arena], now, args.swap, live) { - eprintln!("eye frames stopped (idle); searching again"); + if p.unbalanced { + // The frames did not stop (no headset-off count), but what was learnt from the slow camera's + // may be off: the next lock starts a new wear, as after frames that stopped. + eprintln!("{} (idle); searching again", unbalanced_line(&p.ring, &p.balance)); + } else { + eprintln!("eye frames stopped (idle); searching again"); + // (they stopped LOST_AFTER ago) + off.frames_lost(eyecam::now_boot() - LOST_AFTER); + } pump = None; came_back = true; - // (they stopped LOST_AFTER ago) - off.frames_lost(eyecam::now_boot() - LOST_AFTER); live.send(Msg::Stale); next_search = now + 1.0; if state.0 == "idle" { @@ -2304,6 +2336,8 @@ fn record( let mut reading = prox.read(); let mut valve_seen = 0u64; let mut relocks = 0; + // One camera's frames far slower than the other's (ring::FrameBalance, on `count`) + let mut balance = ring::FrameBalance::default(); let mut abort = None; let mut pending_stop: Option = None; let reason: String = loop { @@ -2415,13 +2449,18 @@ fn record( } } - if now - last_change > LOST_AFTER { - eprintln!("eye frames stopped; searching again ({busy_state} time keeps running)"); + let unbalanced = ring.both_eyes && balance.check(now, count); + if now - last_change > LOST_AFTER || unbalanced { + if unbalanced { + eprintln!("{}; searching again ({busy_state} time keeps running)", unbalanced_line(&ring, &balance)); + } else { + eprintln!("eye frames stopped; searching again ({busy_state} time keeps running)"); + } if let Some(s) = session.as_mut() { s.flush()?; } if let Some(m) = meta.as_mut() { - writeln!(m, "lost_at_t_raw={now:.9}")?; + writeln!(m, "{}_at_t_raw={now:.9}", if unbalanced { "unbalanced" } else { "lost" })?; } if let Some(l) = live.as_deref() { l.send(Msg::Stale); @@ -2459,6 +2498,7 @@ fn record( } slots = SlotStates::new(&b.arenas[ring.arena], &ring); last_change = now_raw(); + balance = ring::FrameBalance::default(); // A calibration keeps its wear (livesvc::NEW_WEAR_OFF_S). if let Some(l) = live.as_deref() { l.send(Msg::NewWear("the eye frames came back")); diff --git a/tools/eyecam/src/ring.rs b/tools/eyecam/src/ring.rs index a233962..a89d760 100644 --- a/tools/eyecam/src/ring.rs +++ b/tools/eyecam/src/ring.rs @@ -481,6 +481,11 @@ pub fn discover(arenas: &[Arena], clock: &mut dyn Clock, log: &mut dyn FnMut(Str let (eye, camera_reason, both_eyes) = cameras_for(&off, pitch, pictures_differ.then_some(&similar[..])); log(format!("cameras {eye:?}: {camera_reason}")); look.slots = off.len(); + if both_eyes && !each_camera_full(&eye) { + // Both cameras, but not all of their slots: a camera with fewer is read at a fraction of its frame rate. + log(format!("not taken: each camera needs {SLOTS_PER_CAMERA} slots")); + return None; + } look.both_eyes = both_eyes; look.stopped_at = if both_eyes { "" } else { STOP_ONE_EYE }; Some(Ring { arena: a, pitch, off, eye, both_eyes, framing, camera_reason }) @@ -499,8 +504,11 @@ pub const SLOTS_PER_CAMERA: usize = 4; /// full ring (2 x SLOTS_PER_CAMERA slots) is always split 4 + 4 by address, each camera having four slots: on /// 2026-10-17 a spacing measured 1 byte off (262209) left the layout without a boundary, and the pictures (both eyes, /// lying down) put a right-eye slot with the left eye's (slot_camera 1,0,1,1,0,0,0,0: one frame in five of the left -/// eye was the right eye's). The pictures decide only for a partial ring without a layout boundary; a disagreement -/// with them is noted as a WARNING either way. +/// eye was the right eye's). The pictures never decide: a partial ring without a layout boundary is one camera's +/// slots, one group (2026-10-17..25, headset not worn: four slots of the left eye's camera alone, at 0x334240, were +/// split by their pictures into 1,0,0,0, 1,0,1,0, 1,0,1,1, 1,1,0,1 and 1,1,1,0, and the right eye was sent the left +/// eye's frames). A disagreement with the pictures is noted as a WARNING. A partial ring with a boundary has both +/// cameras but not all of their slots; `discover` does not take it (`each_camera_full`). pub fn cameras_for(off: &[usize], pitch: usize, similar: Option<&[u8]>) -> (Vec, String, bool) { let full = off.len() == 2 * SLOTS_PER_CAMERA; let (eye, mut reason, both_eyes) = match assign_cameras(off, pitch) { @@ -513,18 +521,11 @@ pub fn cameras_for(off: &[usize], pitch: usize, similar: Option<&[u8]>) -> (Vec< None if full && similar.is_some() => { (by_address(off.len()), "4 + 4 slots by address, lower = R (no group boundary in the layout)".into(), true) } - None => match similar { - Some(similar) => ( - cameras_by_picture(similar), - "picture: no group boundary in the layout; grouped by similarity, lower addresses = R".into(), - true, - ), - None => ( - vec![0; off.len()], - "one group only (one camera streaming; which eye is not known, called L; its frames are not flipped)".into(), - false, - ), - }, + None => ( + vec![0; off.len()], + "one group only (one camera streaming; which eye is not known, called L; its frames are not flipped)".into(), + false, + ), }; if let Some(similar) = similar && similar.iter().map(|&g| g ^ similar[0]).ne(eye.iter().map(|&e| e ^ eye[0])) @@ -561,10 +562,51 @@ pub fn assign_cameras(off: &[usize], pitch: usize) -> Option<(Vec, String)> Some(((0..off.len()).map(|k| LOWER_ADDRESS_EYE ^ (k >= boundary) as u8).collect(), reason)) } -/// The eyes when only the pictures group the slots (`similar`: 0 = like slot 0, 1 = not; slots in memory order): -/// still numbered by address, the group of slot 0 (the lowest address) being LOWER_ADDRESS_EYE. -fn cameras_by_picture(similar: &[u8]) -> Vec { - similar.iter().map(|&g| LOWER_ADDRESS_EYE ^ g ^ similar[0]).collect() +/// Whether each camera holds SLOTS_PER_CAMERA of the slots (`eye`, as `cameras_for` gave them). A ring with fewer for +/// either camera reads that camera at a fraction of its frame rate: 17 of the 138 locks over 2026-10-16..25 (all with the headset +/// not worn, or put on in that same second) had cameras [1, 0, 0, 0, 0] (the right eye at 22.5 of 90 fps, through a +/// calibration and a recording), [1, 1, 1, 0, 0, 0], or one camera's four slots split by their pictures; 120 were +/// 4 + 4 (and one the 8-slot grouping by pictures that cameras_for no longer makes). +pub fn each_camera_full(eye: &[u8]) -> bool { + [0, 1].iter().all(|&e| eye.iter().filter(|&&x| x == e).count() == SLOTS_PER_CAMERA) +} + +/// After a lock: one camera's frames arriving at less than this share of the other's ... +pub const UNBALANCED_SHARE: f64 = 0.5; +/// ... while the faster one delivers at least this many a second, and the slower one some ... +pub const UNBALANCED_MIN_FPS: f64 = 5.0; +/// ... for this long (s) means the ring was locked without all of a camera's slots: look for it again. +pub const UNBALANCED_FOR_S: f64 = 5.0; + +/// Watches the frames each camera delivers after a lock (a safety net behind `each_camera_full`). A camera that stops +/// altogether is not this: the other one goes on as before. +#[derive(Clone, Debug, Default)] +pub struct FrameBalance { + /// The time and the frame counts of the last one-second window's start. + window: Option<(f64, [u64; 2])>, + /// When the frame rates were first unbalanced in a row of windows. + since: Option, + /// The last window's frames a second per camera. + pub fps: [f64; 2], +} + +impl FrameBalance { + /// Each camera's frame count so far (`count`, by camera) at `now` (s). True once one camera has delivered under + /// UNBALANCED_SHARE of the other's frames, but some, for UNBALANCED_FOR_S (judged in windows of a second). + pub fn check(&mut self, now: f64, count: [u64; 2]) -> bool { + let Some((t0, c0)) = self.window else { + self.window = Some((now, count)); + return false; + }; + if now - t0 >= 1.0 { + self.fps = [0, 1].map(|e| count[e].saturating_sub(c0[e]) as f64 / (now - t0)); + let (lo, hi) = (self.fps[0].min(self.fps[1]), self.fps[0].max(self.fps[1])); + let unbalanced = lo > 0.0 && hi >= UNBALANCED_MIN_FPS && lo < UNBALANCED_SHARE * hi; + self.since = if unbalanced { self.since.or(Some(t0)) } else { None }; + self.window = Some((now, count)); + } + self.since.is_some_and(|t| now - t >= UNBALANCED_FOR_S) + } } #[cfg(test)] @@ -594,6 +636,8 @@ mod tests { pad: u8, /// Draw group 0's eye on the right and group 1's on the left (what fooled the old picture rule). mirrored: bool, + /// Slots 0..dark get dark frames (still refreshed, but no picture), as the headset off can show. + dark: usize, } impl Sim { @@ -618,7 +662,7 @@ mod tests { for x in 0..WIDTH { let noise = self.rand() % 8; let r2 = ((x as f64 - cx).powi(2) + (y as f64 - 200.0).powi(2)) / 1600.0; - let v = if y < 4 { + let v = if y < 4 || slot < self.dark { 2 + noise % 3 } else if r2 < 1.0 { 12 + noise @@ -728,6 +772,38 @@ mod tests { } fn find_ring(pad: u8, mirrored: bool, fps: f64) { + let (ring, look, _) = look_at_sim(pad, mirrored, fps, 0); + let ring = ring.expect("ring"); + assert_eq!(ring.arena, 0); + assert_eq!(ring.pitch, PITCH); + let expected: Vec = (0..8).map(|k| header_at(k) + HEADER_BYTES).collect(); + assert_eq!(ring.off, expected); + // The lower-address group (slots 0-3) is the right eye's camera. + assert_eq!(ring.eye, vec![1, 1, 1, 1, 0, 0, 0, 0]); + assert!(ring.both_eyes); + // What the panel's diagnostics show of it: the candidates, how often they refreshed, the slots, both eyes + assert!(look.candidates >= 8, "{look:?}"); + assert!(look.refresh_hz >= if fps < 20.0 { 3.0 } else { 5.0 }, "{look:?}"); + assert_eq!((look.slots, look.both_eyes, look.stopped_at), (8, true, ""), "{look:?}"); + assert!(ring.camera_reason.starts_with("layout: group boundary before slot 4 (spacing 262208+64)")); + assert!(!ring.camera_reason.contains("WARNING"), "{}", ring.camera_reason); + let clean = if pad == 0 { 0.001 } else { 0.51 }; + assert!(ring.framing.pad_dirty.iter().all(|&d| d < clean), "{:?}", ring.framing.pad_dirty); + } + + #[test] + fn a_ring_without_all_of_a_cameras_slots_is_not_taken() { + // The headset off (2026-10-25 17:26): the right eye's camera showed a picture in one of its slots only, and + // the ring walk stopped there: 5 slots, cameras [1, 0, 0, 0, 0], the right eye at 22.5 fps. Not taken now. + let (ring, look, lines) = look_at_sim(0, false, 90.0, 3); + assert!(ring.is_none(), "{ring:?}"); + assert_eq!((look.slots, look.both_eyes, look.stopped_at), (5, false, STOP_FEW_SLOTS), "{look:?}"); + assert!(lines.iter().any(|l| l.starts_with("cameras [1, 0, 0, 0, 0]: layout: group boundary before slot 1")), "{lines:?}"); + assert!(lines.iter().any(|l| l == "not taken: each camera needs 4 slots"), "{lines:?}"); + } + + /// One look (`discover`) at a simulated camera: slots 0..dark (the right eye's camera's) without a picture. + fn look_at_sim(pad: u8, mirrored: bool, fps: f64, dark: usize) -> (Option, Look, Vec) { let mut ring_buf = vec![0u8; 16 << 20]; let mut other_buf = vec![0u8; 32 << 20]; let (ring_ptr, other_ptr) = (ring_buf.as_mut_ptr(), other_buf.as_mut_ptr()); @@ -741,6 +817,7 @@ mod tests { rng: 0x9e3779b97f4a7c15, pad, mirrored, + dark, }; // A stale but eye-like picture elsewhere in the heap, which must not be picked. for y in 0..HEIGHT { @@ -762,27 +839,13 @@ mod tests { } let mut lines = Vec::new(); let mut look = Look::default(); - let ring = discover(&arenas, &mut sim, &mut |line| lines.push(line), &mut look).expect("ring"); + let ring = discover(&arenas, &mut sim, &mut |line| lines.push(line), &mut look); for line in &lines { eprintln!("{line}"); } - assert_eq!(ring.arena, 0); - assert_eq!(ring.pitch, PITCH); - let expected: Vec = (0..8).map(|k| header_at(k) + HEADER_BYTES).collect(); - assert_eq!(ring.off, expected); - // The lower-address group (slots 0-3) is the right eye's camera. - assert_eq!(ring.eye, vec![1, 1, 1, 1, 0, 0, 0, 0]); - assert!(ring.both_eyes); - // What the panel's diagnostics show of it: the candidates, how often they refreshed, the slots, both eyes - assert!(look.candidates >= 8, "{look:?}"); - assert!(look.refresh_hz >= if fps < 20.0 { 3.0 } else { 5.0 }, "{look:?}"); - assert_eq!((look.slots, look.both_eyes, look.stopped_at), (8, true, ""), "{look:?}"); - assert!(ring.camera_reason.starts_with("layout: group boundary before slot 4 (spacing 262208+64)")); - assert!(!ring.camera_reason.contains("WARNING"), "{}", ring.camera_reason); - let clean = if pad == 0 { 0.001 } else { 0.51 }; - assert!(ring.framing.pad_dirty.iter().all(|&d| d < clean), "{:?}", ring.framing.pad_dirty); drop(arenas); drop((ring_buf, other_buf)); + (ring, look, lines) } impl Sim { @@ -880,15 +943,82 @@ mod tests { let (eye, reason, _) = cameras_for(&off, p, Some(&[0, 0, 0, 0, 1, 1, 1, 1])); assert_eq!(eye, vec![1, 1, 1, 1, 0, 0, 0, 0]); assert!(reason.contains("before slot 4") && !reason.contains("WARNING"), "{reason}"); - // A partial ring (a slot dropped) still follows its layout boundary, and without one the pictures + // A partial ring (a slot dropped) still follows its layout boundary (discover then does not take it: + // each_camera_full), and without one it is one camera, whatever the pictures say assert_eq!(cameras_for(&off[1..6], p, None).0, vec![1, 1, 1, 0, 0]); - let (eye, _, both) = cameras_for(&jumbled[..5], p, Some(&[0, 1, 0, 0, 1])); - assert_eq!((eye, both), (vec![1, 0, 1, 1, 0], true)); + let (eye, reason, both) = cameras_for(&jumbled[..5], p, Some(&[0, 1, 0, 0, 1])); + assert_eq!((eye, both), (vec![0; 5], false)); + assert!(reason.starts_with("one group only") && reason.contains("WARNING"), "{reason}"); // One camera streaming: one group, not flipped let (eye, _, both) = cameras_for(&off[..4], p, None); assert_eq!((eye, both), (vec![0; 4], false)); } + #[test] + fn the_partial_rings_locked_on_the_device_are_not_taken() { + // The slots' starts on the device: the right eye's camera at 0x234100.., the left eye's 64 bytes late. + let p = EXPECTED_PITCH; + let slot = |k: usize| 0x23_4100 + k * p + if k >= 4 { 64 } else { 0 }; + let take = |slots: &[usize], similar: Option<&[u8]>| { + let off: Vec = slots.iter().map(|&k| slot(k)).collect(); + let (eye, _, both) = cameras_for(&off, p, similar); + (eye.clone(), both && each_camera_full(&eye)) + }; + // The usual lock (120 of 138 times over 2026-10-16..25): taken + assert_eq!(take(&[0, 1, 2, 3, 4, 5, 6, 7], Some(&[0, 0, 0, 0, 1, 1, 1, 1])), (vec![1, 1, 1, 1, 0, 0, 0, 0], true)); + // "5 slots at 0x2f41c0, cameras [1, 0, 0, 0, 0]" (7 times; 2026-10-25 17:26: the right eye at 22.5 fps) + assert_eq!(take(&[3, 4, 5, 6, 7], None), (vec![1, 0, 0, 0, 0], false)); + // "6 slots at 0x234100, cameras [1, 1, 1, 0, 0, 0]" (a slot of each camera dropped) + assert_eq!(take(&[0, 1, 2, 4, 5, 6], None), (vec![1, 1, 1, 0, 0, 0], false)); + // "4 slots at 0x334240": the left eye's camera alone, which the pictures split into two eyes (1,0,0,0 four + // times, then 1,0,1,0, 1,0,1,1, 1,1,0,1 and 1,1,1,0 twice): one camera now, not both eyes + for similar in [[0, 1, 1, 1], [0, 1, 0, 1], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] { + assert_eq!(take(&[4, 5, 6, 7], Some(&similar)), (vec![0; 4], false), "{similar:?}"); + } + assert!(each_camera_full(&[1, 1, 1, 1, 0, 0, 0, 0]) && !each_camera_full(&[1, 1, 1, 1, 0, 0, 0])); + assert!(!each_camera_full(&[0; 8]) && !each_camera_full(&[])); + } + + #[test] + fn frame_balance_looks_again_only_after_five_seconds_of_one_slow_camera() { + // Frame counts at 100 Hz polls: L at `l` fps, R at `r` fps, from t0 for `secs` seconds. + let run = |b: &mut FrameBalance, t0: f64, secs: f64, l: f64, r: f64, count: &mut [u64; 2]| -> Option { + let mut acc = [0.0f64; 2]; + for i in 0..(secs * 100.0) as usize { + let t = t0 + i as f64 / 100.0; + acc[0] += l / 100.0; + acc[1] += r / 100.0; + for e in 0..2 { + while acc[e] >= 1.0 { + acc[e] -= 1.0; + count[e] += 1; + } + } + if b.check(t, *count) { + return Some(t); + } + } + None + }; + // 2026-10-25 17:26: L 89.5, R 22.5 fps: after 5 s (judged in whole seconds) + let (mut b, mut c) = (FrameBalance::default(), [0u64; 2]); + let at = run(&mut b, 0.0, 10.0, 89.5, 22.5, &mut c).expect("looks again"); + assert!((5.0..6.1).contains(&at), "{at}"); + assert!((b.fps[0] - 89.5).abs() < 1.5 && (b.fps[1] - 22.5).abs() < 1.5, "{:?}", b.fps); + // Balanced (both 90, or 72 against 90 after a rate change), or one camera stopped: never + for (l, r) in [(90.0, 90.0), (72.0, 90.0), (90.0, 0.0), (0.0, 0.0), (3.0, 1.0)] { + let (mut b, mut c) = (FrameBalance::default(), [0u64; 2]); + assert_eq!(run(&mut b, 0.0, 20.0, l, r, &mut c), None, "{l} / {r}"); + } + // Unbalanced for 4 s, then balanced for a second: starts over + let (mut b, mut c) = (FrameBalance::default(), [0u64; 2]); + assert_eq!(run(&mut b, 0.0, 4.5, 90.0, 22.5, &mut c), None); + assert_eq!(run(&mut b, 4.5, 1.6, 90.0, 90.0, &mut c), None); + // (the window from 6 s on is mostly slow already, and counts from its start) + let at = run(&mut b, 6.1, 10.0, 90.0, 22.5, &mut c).expect("looks again"); + assert!(at > 10.99, "{at}"); + } + #[test] fn the_flip_follows_the_camera_and_one_camera_is_never_flipped() { let ring = |eye: Vec, both_eyes| Ring { @@ -910,13 +1040,6 @@ mod tests { assert!((0..4).all(|k| !one.upside_down(k))); } - #[test] - fn pictures_alone_still_name_the_lower_addresses_right() { - // Grouped by similarity to slot 0 (the lowest address): slot 0's group is the right eye's camera. - assert_eq!(cameras_by_picture(&[0, 0, 0, 0, 1, 1, 1, 1]), vec![1, 1, 1, 1, 0, 0, 0, 0]); - assert_eq!(cameras_by_picture(&[0, 1, 0, 1, 1]), vec![1, 0, 1, 0, 0]); - } - #[test] fn frame_test_separates_eyes_from_junk() { let mut eye = vec![0u8; FRAME_BYTES]; From 397f5bbcd0a81c85decfb1b28c03dfbd3c572441 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 17:46:08 +0900 Subject: [PATCH 49/65] =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB:=20=E3=80=8C?= =?UTF-8?q?=E7=B4=B0=E3=82=81=E3=81=AE=E5=AD=A6=E7=BF=92=E7=94=A8=E3=81=AB?= =?UTF-8?q?=E6=92=AE=E3=82=8B=E3=80=8D=E3=81=A7=E3=80=81=E7=89=87=E7=9B=AE?= =?UTF-8?q?=E3=81=AE=E3=82=AB=E3=83=A1=E3=83=A9=E3=81=AE=E6=9E=9A=E6=95=B0?= =?UTF-8?q?=E3=81=8C=E3=82=82=E3=81=86=E7=89=87=E6=96=B9=E3=81=AE=209=20?= =?UTF-8?q?=E5=89=B2=E3=81=AB=E5=B1=8A=E3=81=8B=E3=81=AA=E3=81=84=E9=8C=B2?= =?UTF-8?q?=E7=94=BB=E3=82=92=E3=81=AF=E3=81=98=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 左右のカメラは同じ速さで撮る(119 本の録画と校正で 0.999〜1.000、15 fps の HMD でも同じ)。片方のスロットの一部だけで掴むと 1/4・2/4・3/4 になるので、frames.csv の目ごとの枚数で少ない方 / 多い方 < 0.9 なら Check::Uneven として aborted/ に入れ、 「片目のカメラの映像が遅かったよ(左 8490 / 右 2124 枚)。HMD をかぶり直してからもう一度撮ってね」を出す(EN も。info.txt の why にも)。 --fake-contrib uneven、テスト、panel/README、更新履歴(0.8.2-beta.3)も合わせる。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 1 + CHANGELOG.md | 1 + panel/README.md | 8 +++--- panel/src/contrib.cpp | 9 +++++++ panel/src/contrib.h | 17 ++++++++++--- panel/src/i18n.cpp | 3 +++ panel/src/i18n.h | 1 + panel/src/main.cpp | 11 ++++++--- panel/src/panel.cpp | 7 ++++++ panel/tests/contrib_test.cpp | 47 ++++++++++++++++++++++++++++++------ 10 files changed, 87 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 6f2f955..dd5819b 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -6,6 +6,7 @@ - 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(Google ドライブなどの共有リンクを非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります - 録画は `~/eyecam/contrib/` にたまります。学習に使えるものだけを移します: `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろい、両目のフレームがあり、目ごとの raw がちょうど `frames.csv` のその目の枚数ぶんあり(ディスクがいっぱいで途中で切れたものをはじく)、フレームの 9 割以上に Valve の目のトラッキングの値がある。CSV とファイルの大きさだけで確かめ、まとめるときにも確かめ直します。使えない・途中で止まったものはわけと一緒に `aborted/` に入れて、撮り直してもらいます。隣には、いちばん新しい校正結果・ユーザー校正(`calib.json`)・eyecam の設定の写しを置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません +- 片目のカメラの枚数がもう片方の 9 割に届かない録画(片方のカメラのスロットの一部だけで掴んで、1/4・2/4・3/4 になったもの)もはじき、HMD をかぶり直してから撮り直してもらいます - [撮影をはじめる]は、本体が動いていて目のデータが来ているときだけ押せます(ラベルに要る Valve の値は、本体が頼んでいるあいだしか来ないため)。押せないときはそう出します - 終わりの画面([録ったもの]からも)に、本数と合計の大きさ、フォルダ、送り方、[1 つのファイルにまとめる](`zstd -6` で `.tar.zst`。Frame の目の映像で 0.58 倍だった。7-Zip などで開ける。zstd が無ければ `.tar`。空き容量を確かめてから、低い優先度で裏でまとめる。元の録画は残す)、[録画を消す](確認つき)を出します - eyecam: 新しい `protocol_contrib.txt`。`look_left`(長 2 回、高→低)と `look_right`(長 2 回、低→高)に専用のビープ(今までは細めと同じ音だった)。status.json に `headset_off_count`(eyecam-rec が起動してから、目の映像が止まるか近接センサーで 10 秒以上外していて、またかぶった回数。サスペンドも数える時計で測る)と `started_unix` を足しました diff --git a/CHANGELOG.md b/CHANGELOG.md index c837e7f..7190e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ New in the panel: "Record for squint training" (Advanced › Debug tools, once t - "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (a private share link from Google Drive or similar, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). - The recordings gather in `~/eyecam/contrib/`, each moved there once it can be used for training: `eye_L.raw`, `eye_R.raw`, `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` there, frames of both eyes, each raw file exactly as many frames as `frames.csv` lists for that eye (one cut short when the disk ran full is caught), and Valve's tracking values near at least 90 % of the frames; checked from the CSV files and file sizes alone, again when packing. One that can't be used, or stopped early, goes to `aborted/` with why, to be recorded again. Beside each go copies of the newest calibration result, the user calibration (`calib.json`) and eyecam's settings then. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. +- A recording where one eye's camera delivered under 90 % of the other's frames (a ring locked on part of its slots: a quarter, a half or three quarters) is kept out too, asking to put the headset on again and record again. - "Start recording" waits for frameeyeosc to run with eye data coming in (Valve's values, which the labels need, only come while it asks for them), and says so. - The end's screen (also from "Recordings") shows how many there are and their size, the folder, how to send it, "Make one file" (a `.tar.zst` through `zstd -6`, measured on the Frame's eye video at 0.58 of the size, which 7-Zip and the like open; a `.tar` without zstd; packed in the background at a low priority after checking the free space; the recordings stay) and "Delete the recordings" (after a confirmation). - eyecam: new `protocol_contrib.txt`; new beeps for `look_left` (2 long, high then low) and `look_right` (2 long, low then high), which had the squint's beep; status.json's new `headset_off_count` (the times the headset was taken off for 10 s or more and worn again since eyecam-rec started, by the eye video stopping or the proximity sensor, on a clock that counts suspend) and `started_unix`. diff --git a/panel/README.md b/panel/README.md index f0012e8..5e6ef6b 100644 --- a/panel/README.md +++ b/panel/README.md @@ -243,7 +243,7 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - `--report-make 種類 始め 終わり 結果 理由`(確かめるためのもの): その時間のジャーナルと Valve のログから、パネルが走り終わったあとに書くのと同じ記録を `--reports-dir` に書き、フォルダを出す(例: `--report-make fit "2026-10-06 20:45:09" "2026-10-06 20:45:17" failed "…"`。条件と診断コードは空) - 更新履歴は `--history` で開き、`--history-open 版` でその版の行を開き、`--history-scroll px` でスクロールして描く(どちらも `--history` を含む。スクロールは中身の範囲に収める)。変更履歴は上の場所から探す。`--changelog-dir DIR` でそのフォルダだけを見る(無いフォルダなら「見つかりません」の見た目)。`--fake` とも一緒に使える - `--click X,Y`(何回でも)は、描く前にその座標を押したことにする。当たり判定と設定ファイルの書き込みをヘッドセットなしで確かめる用(`--fake` とは一緒に使えない。`--config` の設定ファイルを本当に書き換えるので、試すときは別の場所を指定する) -- 「細めの学習用に撮る」(下)は `--fake-contrib 状態` で、作り物のフォルダ(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意)・`deleted`(消したあとの同意)・`eyewear`(眼鏡・インサートの質問)・`ready`(撮る前。これまで 3 本)・`noeyedata`(本体に目のデータが来ていない)・`nospace`(空きが足りない)・`running`(録画中: 2 回目の細め)・`running-left`(録画中: 左を見る段)・`failed`(途中で止まった)・`incomplete`(ファイルが足りなかった)・`novideo`(右目の映像がなかった)・`truncated`(raw が途中で切れていた)・`valve`(Valve の値が足りなかった)・`done`(撮れた: 4 本)・`list`([録ったもの]。1 本はファイルが足りずにまとめから外れた)・`packing`(まとめている 45 %)・`packed`(まとめた)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」の行を描く(`none` で 1 本目の前)。何も読み書きしない +- 「細めの学習用に撮る」(下)は `--fake-contrib 状態` で、作り物のフォルダ(目のカメラは準備済みで待機中。`--fake-eyecam` があればそちら)をそのシートで描く: `consent`(同意)・`deleted`(消したあとの同意)・`eyewear`(眼鏡・インサートの質問)・`ready`(撮る前。これまで 3 本)・`noeyedata`(本体に目のデータが来ていない)・`nospace`(空きが足りない)・`running`(録画中: 2 回目の細め)・`running-left`(録画中: 左を見る段)・`failed`(途中で止まった)・`incomplete`(ファイルが足りなかった)・`novideo`(右目の映像がなかった)・`uneven`(片目のカメラの映像が遅かった)・`truncated`(raw が途中で切れていた)・`valve`(Valve の値が足りなかった)・`done`(撮れた: 4 本)・`list`([録ったもの]。1 本はファイルが足りずにまとめから外れた)・`packing`(まとめている 45 %)・`packed`(まとめた)・`delete`(消す確認)・`deleting`。`--adv-page tools` と一緒なら、シートを開かずに「動作確認」の行を描く(`none` で 1 本目の前)。何も読み書きしない - 目の撮影タブ(下)は `--fake-eyecam waiting|idle|confirm|searching|recording:ラベル|error` と `--tab eyecam` で描く(`confirm` は idle で光の注意を開いたところ。`recording:ラベル:unlocked` でカメラが目を見失ったとき=「HMD をかぶってください」、`recording:ラベル:nolight` で光なしのプロトコル=「光なし」。`:pupil=0.97,0.04` で目ごとの瞳の見え具合(`null` も可。無ければ古い eyecam-rec)、`:failed=L|R|LR|mixed`(`:prov`)で片目だけ失敗した校正や両目の失敗のメッセージ。`:page=user:lookdown=L|R|LR` で、下を見たときの補正をその目(LR は両目)だけ標準の値にして通ったユーザー校正のカード、`:lookprev=L|R|LR` で前の値のままにして通ったカード(`lookdown=R:lookprev=L` で目ごとに違うとき)。`:search=not_worn|no_video|one_eye` で映像が見つからない理由(`status.json` の `search`。`not_worn` は近接センサー 12)。ラベルは lead_in・normal・widen・close・squint・look_up・look_down・bright・dark・end。recording は eyecam の `protocol_widen.txt`(17 ステップ・83 秒。`nolight` なら `protocol_widen_nolight.txt` の 14 ステップ)でそのラベルが最初に出るステップになり、その手順をステップのチップにも使う(ファイルは読まず、同じ手順を main.cpp に持っている)。error は失敗した返事も出す)。`--eyecam-fill-png PATH`(`--eyecam-fill bright|dark`、`--language`)で全面の光の画像。`--eyecam-dir DIR` で eyecam-rec のフォルダ(`status.json` と `ctl.sock`)を別の場所にする。`--fake` なしの `--dump-png` はそこを読み、`--click` で[撮影開始]→[光ありで始める]/[光なしで始める]や[中止]を押すとそのソケットに本当に送って返事を待つ(試すときは一時フォルダに偽の `status.json` とソケットを置く。本物の `/run/user/1000/eyecam/` には何も作らない) - `--probe` は Background 型でつなぐだけで、オーバーレイも Vulkan も作らない。`FindOverlay`・名前・幅・閉じるボタン・表示中か・`GetOverlayTextureSize` を出す - `contrib/icons/frameeyeosc-panel-{48,128,256}.png` は `--thumbnail-png` で書き出したもの(ダッシュボードのサムネイルと同じ絵) @@ -270,11 +270,11 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - **眼鏡・インサート**: 「HMD の中で、眼鏡や度付きインサートを使ってる?」「学習のときに分けて見るためだけに使うよ。録画ごとに info.txt に書くよ」、同じ重さの 4 つ[使ってない][眼鏡][度付きインサート][答えない]と[戻る]。答えたら、そのとき同意と答えを `info.txt` に書く(フォルダが無ければ作る) - **撮る前**: 「細めの学習用の撮影(約 1 分半)」と点つきで「最初の 10 秒は、普通に開けて正面を見て待ってね」「ビープと画面の指示に合わせて、閉じる・普通・細める・普通・見開くを 3 回、そのあと上・下・左・右を見るよ」「細めるは、まぶしいときみたいに目を細める(閉じない)」「左・右は自分から見た向き。顔は正面のまま、目だけ動かしてね」「画面が白や黒に光ることはないよ」、録ったものがあれば灰色で「これまで 3 本・合計 8.2 GB」。ボタンの上に灰色で「空き容量 23.2 GB(1 本 約 2.7 GB)」(足りなければ赤で「空きが足りないよ(約 3.8 GB 要る)」、本体が止まっているか目のデータが来ていなければ赤で「目の送信が動いていて、目のデータが来ているときに撮れるよ(Valve の値も一緒に録るため)」、eyecam-rec がほかのことをしていれば「目のカメラがほかのことをしているあいだは待ってね」)、[撮影をはじめる(約 1 分半)]。押せるのは同意が済んで、eyecam-rec が待機中(かエラー)・コマンドの返事待ちでない・まとめていない・1 本ぶん(目のカメラの毎秒の枚数から。分からなければ 90)と 1 GiB が空いている・本体が動いていて目のデータが来ている(状態ファイルの `running` と `tracking`。Valve のサンプルは本体が頼んでいるあいだしか来ないので、無いまま撮ると 93 秒あとに「Valve の値が 0 %」で aborted になる)とき(`contrib::startAllowed`) - **録画中**: eyecam-rec が探している・録っているあいだは目の撮影の画面(`drawRun`)のまま、見出しだけ「細めの学習用に撮る」(その前後のつなぎ目は見出しと「送っています…」だけ)。ステップのチップは `protocol_contrib.txt` から(23 ステップ)。左右を見る段は「左を見て」「右を見て」(チップは「左を見る」「右を見る」)。シートを閉じていても、詳細タブの録画の画面は同じ見出しになる - - **録れなかった**: 赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった・ファイルが足りなかった〔そのファイル名〕・片目か両目の映像が 1 枚もなかった〔「右目の映像がなかったよ。目のカメラの準備を確かめて」〕・ファイルが途中で切れていた〔「空き容量を確かめて」〕・Valve の目のトラッキングの値が足りなかった〔フレームの何 % にあったか。「SteamVR の目のトラッキングが動いてるか確かめて」〕。どれも「もう一度撮ってね」)、録画のフォルダがあれば灰色で「この録画は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度撮る] + - **録れなかった**: 赤で「最後まで録れなかったよ」、わけ(途中で止まった〔中止か目が見えなくなった〕・eyecam-rec のエラー〔英語の画面ではその英語〕・始められなかった〔返事の err〕・eyecam-rec が止まった・始まらなかった・フォルダが無い・移せなかった・ファイルが足りなかった〔そのファイル名〕・片目か両目の映像が 1 枚もなかった〔「右目の映像がなかったよ。目のカメラの準備を確かめて」〕・片目のカメラの映像が遅かった〔「片目のカメラの映像が遅かったよ(左 8490 / 右 2124 枚)。HMD をかぶり直してからもう一度撮ってね」〕・ファイルが途中で切れていた〔「空き容量を確かめて」〕・Valve の目のトラッキングの値が足りなかった〔フレームの何 % にあったか。「SteamVR の目のトラッキングが動いてるか確かめて」〕。どれも「もう一度撮ってね」)、録画のフォルダがあれば灰色で「この録画は aborted/ に入れたよ(送らなくて大丈夫)」、[もう一度撮る] - **終わり**(撮れたあと、または[録ったもの]): 撮れたあとは緑のチェックと「撮れたよ。ありがとう!」、[録ったもの]なら「録ったもの」。「これまで 4 本・合計 10.9 GB」、灰色で「場所: ~/eyecam/contrib/」、撮れたあとは「別の日や、かぶり直したあとにもう一度押してくれると、もっと助かるよ」、送り方の注意の枠。[1 つのファイルにまとめる](アクセント)と右に「元の録画はそのまま残るよ(空きが約 8.7 GB 要る)」、まとめている間は「まとめています… 45%」とバー、できたら緑で「できたよ(4 本・6.3 GB)」とその下にファイル名(それより本数が増えるまでは押せない)、失敗したら赤でそのわけ、空きが足りなければ赤で「まとめるには空きが足りないよ(約 X 要る)」。その下に灰色で「まとめたファイルは .tar.zst(7-Zip などで開けるよ)」(zstd が無くて .tar になるときは出さない)。まとめるときに学習に使えなかった録画は赤で「rec_… は学習に使えない形だったので入れなかったよ。もう一度撮ってね」。下の左に[録画を消す]、右に[閉じる] - **消す確認**: 赤い枠のカードで「撮った録画を全部消す?」「~/eyecam/contrib/ を中身ごと(10.9 GB)消します。元に戻せないよ。開発者に送ったあとで消してね。」、[やめる](アクセント)と[消す]。消している間は「消しています…」、消したら同意の画面(同意も `info.txt` と一緒に消える) -- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib/info.txt` を読み、シートを開くたびに読み直す(手で消したり変えたりしたフォルダもそのとおりに出る。前の書き込みの失敗の文は消す。追っている録画とまとめ・消すの仕事はそのまま)。[撮影をはじめる]は `ctl.sock` に `start contrib` を送り、そのときの値を覚える(下の `info.txt`)。終わりは `status.json` だけで見る(`contrib::RunWatch`): `contrib` の `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだもので学習に使えるものは `rename` でフォルダへ移し、撮り始めたときに読んだ `~/eyecam/calib_日時/calib_result.json` のいちばん新しいもの・`~/.config/eyecam/calib.json`(ユーザー校正)・`~/.config/eyecam/settings.json` の写しを、`rec_日時.calib_result.json`・`rec_日時.user_calib.json`・`rec_日時.settings.json` として隣に置く(無いものは `info.txt` に `none`)。学習に使える録画かは `contrib::checkRecording` が CSV の行と raw の大きさだけで見る(映像は読まない。3 分の録画でも数十 ms): `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` の 6 つがそろっている、`frames.csv` に両目のフレームがある(片目でも 0 枚なら、その目の映像が来ていない)、目ごとの raw の大きさが `frames.csv` のその目の行数 × 1 フレーム(`meta.txt` の `frame_bytes`、無ければ 160000 B)とちょうど同じ(ディスクがいっぱいで途中で切れた raw をはじく)、`frames.csv` のフレームの 9 割以上に、`t_cam` の前後 0.05 秒以内の使える Valve のサンプル(`valve.csv` の `producer_state` が 1 で `open_l`・`open_r` が数。`sample_time` は `t_cam` と同じ時計)がある(ラベルは Valve の開き具合からも作るので)。Frame の自分の録画 13 本はどれも通った。使えない・止まった・失敗したものは `aborted/` へ(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない)。パネルが録画の途中で起動し直したときは追わない(その録画は `~/eyecam/rec_日時/` に残る) -- **info.txt**(`~/eyecam/contrib/info.txt`): 初めに何のファイルかのコメントと `format=2`、そのあと出来事ごとに節を書き足す。`[consent]`(`time`・`version`・`eyewear`: `none` / `glasses` / `inserts` / `no_answer`。最後の節が効く)、`[recording]`(`rec`・`protocol`・`start`・`start_unix`・`end_unix`・`bytes`・`app_version`・`eyecam_rec_sha256`〔入っている eyecam-rec の SHA-256 の先頭 8 桁。git のハッシュは持っていない〕・`lid_veto`(`on` / `off` / `unknown`)・`widen`・`widen_sensitivity`・`camera_lids`・`eyewear`・`camera_fps`・`minutes_since_previous`(前の録画が終わってから何分。1 本目は `none`)・`headset_off`(そのあいだに HMD を外したか: `yes` / `no` / `unknown` / 1 本目は `none`)・`headset_off_count_start` / `_end`・`eyecam_started_unix`・`calib_result`・`user_calib`・`settings`)、`[aborted]`(`rec`・`why`: `stopped` / `failed` / `missing cues.csv` / `no frames of eye R` / `cut short: eye_R.raw` / `Valve's samples for only 40% of the frames`)、`[packed]`(`file`・`recordings`・`bytes`)。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) +- **進め方**(`contrib::Session`、パネルのループ): パネルの起動時に `~/eyecam/contrib/info.txt` を読み、シートを開くたびに読み直す(手で消したり変えたりしたフォルダもそのとおりに出る。前の書き込みの失敗の文は消す。追っている録画とまとめ・消すの仕事はそのまま)。[撮影をはじめる]は `ctl.sock` に `start contrib` を送り、そのときの値を覚える(下の `info.txt`)。終わりは `status.json` だけで見る(`contrib::RunWatch`): `contrib` の `searching` / `recording` を見たあと(か、送ったあとに作られた録画のフォルダがあれば)`idle` に戻って `session_dir` があり `last_session_aborted` が false なら済み、true か `session_dir` が空(探している間に中止)なら止まった、`error` なら失敗。送って 8 秒たっても動いていなければ始まらなかった、`status.json` が 10 秒新しくならなければ eyecam-rec が止まった。済んだもので学習に使えるものは `rename` でフォルダへ移し、撮り始めたときに読んだ `~/eyecam/calib_日時/calib_result.json` のいちばん新しいもの・`~/.config/eyecam/calib.json`(ユーザー校正)・`~/.config/eyecam/settings.json` の写しを、`rec_日時.calib_result.json`・`rec_日時.user_calib.json`・`rec_日時.settings.json` として隣に置く(無いものは `info.txt` に `none`)。学習に使える録画かは `contrib::checkRecording` が CSV の行と raw の大きさだけで見る(映像は読まない。3 分の録画でも数十 ms): `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` の 6 つがそろっている、`frames.csv` に両目のフレームがある(片目でも 0 枚なら、その目の映像が来ていない)、少ない方の目の枚数が多い方の 9 割以上(ふつうは 0.999〜1.000。15 fps の HMD でも左右は同じ速さ。片方のカメラのスロットの一部だけで掴むと 1/4・2/4・3/4 になる)、目ごとの raw の大きさが `frames.csv` のその目の行数 × 1 フレーム(`meta.txt` の `frame_bytes`、無ければ 160000 B)とちょうど同じ(ディスクがいっぱいで途中で切れた raw をはじく)、`frames.csv` のフレームの 9 割以上に、`t_cam` の前後 0.05 秒以内の使える Valve のサンプル(`valve.csv` の `producer_state` が 1 で `open_l`・`open_r` が数。`sample_time` は `t_cam` と同じ時計)がある(ラベルは Valve の開き具合からも作るので)。Frame の自分の録画 13 本はどれも通った。使えない・止まった・失敗したものは `aborted/` へ(`~/eyecam` の直下の `rec_日時` だけ。ほかは動かさない)。パネルが録画の途中で起動し直したときは追わない(その録画は `~/eyecam/rec_日時/` に残る) +- **info.txt**(`~/eyecam/contrib/info.txt`): 初めに何のファイルかのコメントと `format=2`、そのあと出来事ごとに節を書き足す。`[consent]`(`time`・`version`・`eyewear`: `none` / `glasses` / `inserts` / `no_answer`。最後の節が効く)、`[recording]`(`rec`・`protocol`・`start`・`start_unix`・`end_unix`・`bytes`・`app_version`・`eyecam_rec_sha256`〔入っている eyecam-rec の SHA-256 の先頭 8 桁。git のハッシュは持っていない〕・`lid_veto`(`on` / `off` / `unknown`)・`widen`・`widen_sensitivity`・`camera_lids`・`eyewear`・`camera_fps`・`minutes_since_previous`(前の録画が終わってから何分。1 本目は `none`)・`headset_off`(そのあいだに HMD を外したか: `yes` / `no` / `unknown` / 1 本目は `none`)・`headset_off_count_start` / `_end`・`eyecam_started_unix`・`calib_result`・`user_calib`・`settings`)、`[aborted]`(`rec`・`why`: `stopped` / `failed` / `missing cues.csv` / `no frames of eye R` / `uneven frames (left/right): 8490/2124` / `cut short: eye_R.raw` / `Valve's samples for only 40% of the frames`)、`[packed]`(`file`・`recordings`・`bytes`)。名前などの個人の情報は入れない(ユーザー名は書かず、場所は `~/` から) - **外したかの見分け**: eyecam-rec の `status.json` の `headset_off_count`(起動してから、目の映像が 10 秒以上止まったか近接センサーが 10 秒以上「かぶっていない」になって、そのあとまたかぶった回数。サスペンドも数える時計で測る。tools/eyecam の README)と `started_unix` で決める。前の録画の終わりと今回の始まりで eyecam-rec が同じ起動(`started_unix` が同じ)なら、数が増えていれば `yes`、同じなら `no`。eyecam-rec が起動し直していた・古い eyecam-rec で値が無いときは `unknown`。続けて 2 回押しただけの録画を別のかぶりとして数えないため - **まとめる**(`contrib::Worker`、別のスレッド、nice 10): 別のスレッドの中で録画を同じ条件(`checkRecording`)で確かめ直してから、`info.txt`、使える録画と隣の写しだけを ustar の tar(中のフォルダ名は `contrib/`。8 GiB 以上の大きさは GNU の base-256)にして、zstd があれば `zstd -6 -T2` に通し、`contrib_日時.tar.zst`(無ければ `.tar`)にする。Frame の目の映像 2 GB で測ると、zstd -3 は 0.62 倍・7.7 秒、-6 は 0.58 倍・20 秒、-9 は 0.56 倍・43 秒、-12 は 0.55 倍・90 秒、zip -6 は 0.60 倍・166 秒、gzip -1 は 0.62 倍・19 秒だったので、-6 にした(SteamOS には zstd も tar も zip も入っている。7z は無い)。`.part` に書いて fsync してから名前を変え、失敗したら `.part` を消す。始める前に、その場で読み直した空きで確かめる(録画の 0.7 倍〔zstd なし: 1 倍〕と 1 GiB。前にまとめたファイルは今の空きにもう入っていない〔新しいのができてから消す〕ので、足さない)。できたら `[packed]` を書き足し、前にまとめたファイル(本数の少ないもの)は消す。元の録画には触らない - **消す**: 確認のあと、`~/eyecam/contrib` だけを(中のリンクはたどらずに)丸ごと消す diff --git a/panel/src/contrib.cpp b/panel/src/contrib.cpp index 6a451f7..8f0c291 100644 --- a/panel/src/contrib.cpp +++ b/panel/src/contrib.cpp @@ -521,6 +521,13 @@ CheckResult checkRecording(const std::string& dir) { result.detail = std::string(count[0] == 0 ? "L" : "") + (count[1] == 0 ? "R" : ""); return result; } + // One eye's camera far slower than the other's: its video came only in part (a ring locked on some of its slots) + const double fewer = static_cast(std::min(count[0], count[1])); + if (fewer < kEvenShare * static_cast(std::max(count[0], count[1]))) { + result.check = Check::Uneven; + result.detail = std::to_string(count[0]) + "/" + std::to_string(count[1]); + return result; + } for (int eye = 0; eye < 2; ++eye) { const std::string raw = eye == 0 ? "eye_L.raw" : "eye_R.raw"; struct stat st {}; @@ -1261,11 +1268,13 @@ void Session::ended(View& view, const RunEnd& end, const eyecam::View& eyecam, d if (end.outcome == Outcome::Done) { const Why reason = check.check == Check::Missing ? Why::Incomplete : check.check == Check::NoVideo ? Why::NoVideo + : check.check == Check::Uneven ? Why::Uneven : check.check == Check::Truncated ? Why::Truncated : Why::Valve; view.failure = {true, reason, check.detail, check.detail, ""}; why = check.check == Check::Missing ? "missing " + check.detail : check.check == Check::NoVideo ? "no frames of eye " + check.detail + : check.check == Check::Uneven ? "uneven frames (left/right): " + check.detail : check.check == Check::Truncated ? "cut short: " + check.detail : "Valve's samples for only " + check.detail + " of the frames"; } diff --git a/panel/src/contrib.h b/panel/src/contrib.h index 8b83a3b..e1917db 100644 --- a/panel/src/contrib.h +++ b/panel/src/contrib.h @@ -40,6 +40,10 @@ constexpr const char* kAbortedDir = "aborted"; constexpr const char* kRequiredFiles[] = {"eye_L.raw", "eye_R.raw", "frames.csv", "valve.csv", "cues.csv", "meta.txt"}; /** A raw frame's size when meta.txt doesn't say (frame_bytes: 400x400 grey, or 512x400 with --full-width). */ constexpr long long kRawFrameBytes = 160000; +/** The eye with fewer frames needs at least this share of the other's: both cameras run at the same rate (0.999 to + * 1.000 in 119 recordings and calibrations, at 15 fps too); a ring locked on part of one camera's slots gives one + * eye a quarter, a half or three quarters of the frames. */ +constexpr double kEvenShare = 0.9; /** At least this share of the frames needs a usable Valve sample near it (the labels use Valve's openness)... */ constexpr double kValveShare = 0.9; /** ...within this long of its camera time (s; Valve's samples come about 90 times a second, on the same clock). */ @@ -268,6 +272,7 @@ enum class Check { Ok, Missing, ///< a file of kRequiredFiles isn't there NoVideo, ///< an eye has no frame at all (its camera's video didn't come) + Uneven, ///< one eye has under kEvenShare of the other's frames (its camera's video came only in part) Truncated, ///< an eye's raw file doesn't hold as many frames as frames.csv lists for it (the disk ran full) Valve, ///< under kValveShare of the frames have a usable Valve sample near them (Valve's tracking off?) }; @@ -275,13 +280,15 @@ enum class Check { /** checkRecording's answer. */ struct CheckResult { Check check = Check::Ok; - std::string detail; ///< the files missing, the eyes without frames ("L", "R", "LR"), the raw files cut short - ///< ("eye_L.raw"), or the share paired ("42%") + std::string detail; ///< the files missing, the eyes without frames ("L", "R", "LR"), each eye's frames + ///< ("8490/2124", left/right), the raw files cut short ("eye_L.raw"), or the share paired + ///< ("42%") }; /** * Whether a recording can be used for training, from its CSV files and its raw files' sizes alone (never the - * frames): all of kRequiredFiles there; frames of both eyes in frames.csv; each eye's raw file exactly as many + * frames): all of kRequiredFiles there; frames of both eyes in frames.csv, the fewer at least kEvenShare of the + * more; each eye's raw file exactly as many * frames (meta.txt's frame_bytes each, else kRawFrameBytes) as frames.csv has rows for that eye; and at least * kValveShare of frames.csv's frames with a usable Valve sample in valve.csv (producer_state 1, both openness values * numbers) within kValvePairSec of their t_cam (sample_time is on the same clock). @@ -330,7 +337,9 @@ enum class Outcome { }; /** Why a try didn't finish (the screen says it). */ -enum class Why { None, Stopped, Error, Refused, Gone, NoStart, Missing, Move, Incomplete, NoVideo, Truncated, Valve }; +enum class Why { + None, Stopped, Error, Refused, Gone, NoStart, Missing, Move, Incomplete, NoVideo, Uneven, Truncated, Valve +}; /** A recording's end. */ struct RunEnd { diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index 99f8e70..285b14f 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -936,6 +936,7 @@ UiText makeJapanese() { t.contribWhyIncompleteFormat = "録画のファイルが足りなかったよ(%s)。もう一度撮ってね"; t.contribWhyNoVideoFormat = "%sの映像がなかったよ。目のカメラの準備を確かめて、もう一度撮ってね"; t.contribBothEyes = "両目"; + t.contribWhyUnevenFormat = "片目のカメラの映像が遅かったよ(左 %s / 右 %s 枚)。HMD をかぶり直してからもう一度撮ってね"; t.contribWhyTruncatedFormat = "録画のファイルが途中で切れていたよ(%s)。空き容量を確かめて、もう一度撮ってね"; t.contribWhyValveFormat = "Valve の目のトラッキングの値が、フレームの %s にしかなかったよ。" "SteamVR の目のトラッキングが動いてるか確かめて、もう一度撮ってね"; @@ -1994,6 +1995,8 @@ UiText makeEnglish() { t.contribWhyIncompleteFormat = "The recording lacks files (%s). Please record again"; t.contribWhyNoVideoFormat = "No video from an eye camera (%s). Check the eye cameras' setup and record again"; t.contribBothEyes = "both"; + t.contribWhyUnevenFormat = "One eye camera's video was slow (left %s / right %s frames). Put the headset on again " + "and record again"; t.contribWhyTruncatedFormat = "The recording's files were cut short (%s). Check the free space and record again"; t.contribWhyValveFormat = "Valve's eye tracking values were there for only %s of the frames. Check that " "SteamVR's eye tracking is on and record again"; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index 25613c5..5d4b63a 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -932,6 +932,7 @@ struct UiText { const char* contribWhyIncompleteFormat; ///< (%s: the files missing) const char* contribWhyNoVideoFormat; ///< (%s: the eye without frames: leftEye, rightEye or contribBothEyes) const char* contribBothEyes; ///< ...both + const char* contribWhyUnevenFormat; ///< (%s, %s: the left eye's and the right eye's frames) const char* contribWhyTruncatedFormat; ///< (%s: the raw files cut short) const char* contribWhyValveFormat; ///< (%s: the share of frames with Valve's values, "42%") const char* contribKept; ///< ...its part went to aborted/ diff --git a/panel/src/main.cpp b/panel/src/main.cpp index 6310992..b92896d 100644 --- a/panel/src/main.cpp +++ b/panel/src/main.cpp @@ -388,8 +388,9 @@ void printUsage() { " and idle unless --fake-eyecam says otherwise): consent, deleted, eyewear,\n" " ready, nospace, noeyedata (frameeyeosc gets no eye data), running (a squint),\n" " running-left (looking left), failed,\n" - " incomplete, novideo, truncated, valve (not usable: a file missing, an eye's video\n" - " missing, a raw file cut short, Valve's values missing), done (just\n" + " incomplete, novideo, uneven, truncated, valve (not usable: a file missing, an\n" + " eye's video missing, one eye's camera slow, a raw file cut short, Valve's values\n" + " missing), done (just\n" " recorded), list (\"Recordings\"), packing, packed, delete, deleting (with --adv-page tools: its\n" " row on Debug tools, the sheet closed; none for the row before the first)\n" " --fake-camera both|left|right|uncalibrated|absent|error|off The eye cameras as frameeyeosc reports\n" @@ -463,7 +464,7 @@ bool parsePreviewSpec(const std::string& text, eye_preview::Source& source, bool /** --fake-contrib's screens (fakeContrib). */ const std::string kFakeContribStates[] = {"none", "consent", "deleted", "eyewear", "ready", "nospace", - "running", "failed", "incomplete", "novideo", "truncated", "valve", + "running", "failed", "incomplete", "novideo", "uneven", "truncated", "valve", "noeyedata", "running-left", "done", "list", "packing", "packed", "delete", "deleting"}; @@ -1714,6 +1715,10 @@ void fakeContrib(PanelModel& m, const std::string& state, bool eyecamGiven) { if (state == "novideo") { c.failure = {true, contrib::Why::NoVideo, "R", "R", "aborted/rec_2026-10-25_14-20-55"}; } + if (state == "uneven") { + // (the recording that passed before this check: the right eye a quarter of the left's) + c.failure = {true, contrib::Why::Uneven, "8490/2124", "8490/2124", "aborted/rec_2026-10-25_17-28-41"}; + } if (state == "noeyedata") m.status.tracking = false; if (state == "truncated") { c.failure = {true, contrib::Why::Truncated, "eye_R.raw", "eye_R.raw", "aborted/rec_2026-10-25_14-20-55"}; diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index 0bb8357..03cf953 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -7220,6 +7220,13 @@ void EyePanel::drawContrib(const Pen& pen, const UiText& t, const PanelModel& m) : message == "R" ? t.rightEye : t.contribBothEyes); break; + case contrib::Why::Uneven: { + // ("8490/2124": the left eye's frames, then the right eye's) + const size_t slash = message.find('/'); + why = formatLong(t.contribWhyUnevenFormat, message.substr(0, slash), + slash == std::string::npos ? std::string() : message.substr(slash + 1)); + break; + } case contrib::Why::Truncated: why = formatLong(t.contribWhyTruncatedFormat, message); break; case contrib::Why::Valve: why = formatLong(t.contribWhyValveFormat, message); break; } diff --git a/panel/tests/contrib_test.cpp b/panel/tests/contrib_test.cpp index 8f64bf9..67cb2de 100644 --- a/panel/tests/contrib_test.cpp +++ b/panel/tests/contrib_test.cpp @@ -634,6 +634,7 @@ enum class Defect { FewValve, ///< Valve's tracking stopped after a third of it ValveOff, ///< Valve's samples there but not tracking (producer_state 0) NoFrames, ///< frames.csv with no frame + Uneven, ///< the right eye's camera only every 4th frame (a ring locked on part of its slots) }; /** A made-up frame's size (its meta.txt says so, as eyecam-rec's does). */ @@ -659,14 +660,19 @@ std::string makeRecording(const std::string& home, const std::string& name, int if (defect != Defect::NoRaw) { writeFile(dir + "/eye_L.raw", defect == Defect::LongerRaw ? video + video.substr(0, 1000) : video); } - writeFile(dir + "/eye_R.raw", defect == Defect::Truncated ? video.substr(0, video.size() - 500) : video); + // (uneven: the right eye's raw holds its every 4th frame only, as frames.csv lists it) + const int rightRows = defect == Defect::Uneven ? (rows + 3) / 4 : rows; + const std::string right = video.substr(0, static_cast(rightRows * kTestFrameBytes)); + writeFile(dir + "/eye_R.raw", defect == Defect::Truncated ? right.substr(0, right.size() - 500) : right); std::string csv = "index,eye,eye_index,slot,t_cam,t_raw,t_copy,valve_seq\n"; char row[160]; for (int k = 0; k < rows; ++k) { for (int eye = 0; eye < 2; ++eye) { + if (eye == 1 && defect == Defect::Uneven && k % 4 != 0) continue; const double t = 100.0 + k / 90.0 + eye * 0.0001; - std::snprintf(row, sizeof(row), "%d,%s,%d,%d,%.9f,%.9f,%.9f,%d\n", 2 * k + eye, eye == 0 ? "L" : "R", k, - eye * 4, t, t + 0.006, t + 0.01, 5000 + k); + const int index = eye == 1 && defect == Defect::Uneven ? k / 4 : k; + std::snprintf(row, sizeof(row), "%d,%s,%d,%d,%.9f,%.9f,%.9f,%d\n", 2 * k + eye, eye == 0 ? "L" : "R", + index, eye * 4, t, t + 0.006, t + 0.01, 5000 + k); csv += row; } } @@ -725,6 +731,28 @@ void testCheck() { r = checkRecording(oneEye); CHECK(r.check == Check::NoVideo); SAME(r.detail, "R"); + // One eye's camera a quarter of the other's (a ring locked on part of its slots): uneven, each eye's frames + r = checkRecording(makeRecording(home, "rec_2026-10-25_14-00-11", 100, kNow, Defect::Uneven)); + CHECK(r.check == Check::Uneven); + SAME(r.detail, "100/25"); + // ...the line at 0.9: 90 of 100 passes, 89 doesn't (either eye the fewer) + const auto dropRows = [&](const std::string& eye, int keep) { + const std::string dir = makeRecording(home, "rec_2026-10-25_14-01-" + std::to_string(keep % 100), 100); + std::string kept; + std::istringstream all(readFile(dir + "/frames.csv")); + int seen = 0; + for (std::string line; std::getline(all, line);) { + if (line.find("," + eye + ",") != std::string::npos && seen++ >= keep) continue; + kept += line + "\n"; + } + writeFile(dir + "/frames.csv", kept); + std::filesystem::resize_file(dir + (eye == "L" ? "/eye_L.raw" : "/eye_R.raw"), keep * kTestFrameBytes); + return checkRecording(dir); + }; + CHECK(dropRows("R", 90).check == Check::Ok); + r = dropRows("L", 89); + CHECK(r.check == Check::Uneven); + SAME(r.detail, "89/100"); // meta.txt's frame_bytes counts; without it, eyecam-rec's 160000 const std::string sized = makeRecording(home, "rec_2026-10-25_14-00-06"); writeFile(sized + "/meta.txt", "protocol=contrib\n"); @@ -1060,15 +1088,20 @@ void testSession() { CHECK(view.info.recordings.size() == 4 && view.failure.why == Why::Valve); SAME(view.failure.message, "40%"); CHECK(there(view.dir + "/aborted/rec_2026-10-25_14-18-45")); - CHECK(view.info.aborted.size() == 3); - if (view.info.aborted.size() == 3) { + record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-18-50", Defect::Uneven); + CHECK(view.info.recordings.size() == 4 && view.failure.why == Why::Uneven); + SAME(view.failure.message, "60/15"); + CHECK(there(view.dir + "/aborted/rec_2026-10-25_14-18-50")); + CHECK(view.info.aborted.size() == 4); + if (view.info.aborted.size() == 4) { SAME(view.info.aborted[0], "aborted/rec_2026-10-25_14-18-00 missing cues.csv"); SAME(view.info.aborted[1], "aborted/rec_2026-10-25_14-18-30 cut short: eye_R.raw"); SAME(view.info.aborted[2], "aborted/rec_2026-10-25_14-18-45 Valve's samples for only 40% of the frames"); + SAME(view.info.aborted[3], "aborted/rec_2026-10-25_14-18-50 uneven frames (left/right): 60/15"); } // One stopped part way: into aborted/ too record(session, view, home, now, eyecamReady(now, 0, now - 5), "rec_2026-10-25_14-19-00", Defect::None, true); - CHECK(view.failure.why == Why::Stopped && view.info.aborted.size() == 4); + CHECK(view.failure.why == Why::Stopped && view.info.aborted.size() == 5); // Refused at once (a reply with an error): failed with its reason { const eyecam::View eyecam = eyecamReady(now); @@ -1091,7 +1124,7 @@ void testSession() { View again; Session restarted; restarted.load(again, home); - CHECK(again.exists && again.info.recordings.size() == 4 && again.info.aborted.size() == 4); + CHECK(again.exists && again.info.recordings.size() == 4 && again.info.aborted.size() == 5); CHECK(again.info.eyewear == Eyewear::Glasses && !consentNeeded(again.info)); } // Packed (tar); another recording, packed again: the first pack goes, the recordings stay From fc0559dcfe3b030ed460b4c6cc9a9e099c665579 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 17:48:23 +0900 Subject: [PATCH 50/65] =?UTF-8?q?eyecam:=20=E5=90=8C=E3=81=98=E3=82=B9?= =?UTF-8?q?=E3=83=AD=E3=83=83=E3=83=88=E3=81=AE=E3=83=AA=E3=83=B3=E3=82=B0?= =?UTF-8?q?=E3=81=8C=E3=81=BE=E3=81=9F=E3=82=A2=E3=83=B3=E3=83=90=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=81=AB=E3=81=AA=E3=81=A3=E3=81=9F=E3=82=89?= =?UTF-8?q?=E3=80=81=E3=82=82=E3=81=86=E6=8E=A2=E3=81=97=E7=9B=B4=E3=81=95?= =?UTF-8?q?=E3=81=AA=E3=81=84=EF=BC=88=E3=83=AD=E3=83=83=E3=82=AF=E3=81=A8?= =?UTF-8?q?=E6=8E=A2=E3=81=97=E7=9B=B4=E3=81=97=E3=81=AE=E7=B9=B0=E3=82=8A?= =?UTF-8?q?=E8=BF=94=E3=81=97=E3=82=92=E6=AD=A2=E3=82=81=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - チェック担当の指摘: 4+4 がそろっていても片方のカメラが本当にずっと遅い HMD だと、ロック → 5 s → 探し直し → 同じリング → … が永久に続き、毎回 1〜2 s のフレームを失い、record() の relocks も増え続ける - ring::Relooks: アンバランスで探し直したリング(スロットの位置 off)を覚え、同じ off でまたアンバランスになったら 探し直さずにそのリングを使い続ける(ログは 1 回だけ「on the same slots as before: kept」、FrameBalance::give_up)。 待機中(idle)と録画・校正中(record)でそれぞれ、最後の 8 つのリングを覚える - idle のコメントを「新しいかぶりにする(目がかぶっているように見えなければ: new_wear_hint)」に直す - テスト: 同じスロットは 1 回だけ探し直す、別のリングはまた 1 回、覚えるのは最後の 8 つ。give_up のあとは探し直さない Co-Authored-By: Claude Opus 5.5 --- tools/eyecam/README.md | 4 +- tools/eyecam/src/bin/eyecam-rec.rs | 35 +++++++++++++---- tools/eyecam/src/ring.rs | 62 +++++++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index af6279a..492817d 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -1140,7 +1140,9 @@ sudo で動かすのは、自分でビルドしたもの、またはリリース 分けない(左目のカメラの 4 スロットを絵で 2 つに分けて、右目に左目の映像を送っていた)。 念のため、ロックしたあと片方のカメラの枚数がもう片方の半分未満(少しは来ている)の状態が 5 秒続いたら、探し直す (ログ `one eye camera's frames came far slower …`。録画・校正中なら meta.txt に `unbalanced_at_t_raw`)。 - 片方のカメラが止まったときはこれに当たらず、今までどおりもう片方で続ける + 片方のカメラが止まったときはこれに当たらず、今までどおりもう片方で続ける。探し直して同じスロットのリングが見つかり、 + またそうなったら、そのリングではもう探し直さない(ログに 1 回だけ `on the same slots as before: kept`。本当に片方のカメラが + 遅い HMD で、ロックと探し直しを繰り返さないため。待機中と録画・校正中でそれぞれ、最後の 8 つのリングを覚えておく) - 正確な先頭位置は eyecam 独自で、**スロットごと**に決める: ピクセルは黒くても 0 にならず(黒レベル約 4)毎フレーム変わる一方、 パディング列と行間のすき間は 0 のまま変わらない。その境目がぴったり合う位置を先頭にする(パディング削除がずれない/ヘッダが先頭の直前に来る) - ヘッダの先頭 8 バイトはカメラのタイムスタンプ(u64、CLOCK_MONOTONIC_RAW の ns。90 fps で 11.11 ms 間隔)。残り 56 バイトは今のところ 0。 diff --git a/tools/eyecam/src/bin/eyecam-rec.rs b/tools/eyecam/src/bin/eyecam-rec.rs index a674f8d..cacd7f6 100644 --- a/tools/eyecam/src/bin/eyecam-rec.rs +++ b/tools/eyecam/src/bin/eyecam-rec.rs @@ -980,8 +980,9 @@ impl Pump { } /// One pass over the slots. False once frames have stopped for LOST_AFTER, or one camera's have come far slower - /// than the other's for ring::UNBALANCED_FOR_S (`unbalanced`). - fn poll(&mut self, arena: &Arena, now: f64, swap: bool, live: &mut Live) -> bool { + /// than the other's for ring::UNBALANCED_FOR_S (`unbalanced`) on slots not looked for again before (`relooks`; + /// slots that were are kept, said once). + fn poll(&mut self, arena: &Arena, now: f64, swap: bool, live: &mut Live, relooks: &mut ring::Relooks) -> bool { for k in 0..self.ring.off.len() { match self.slots.poll(arena, &self.ring, k, now, &mut self.buf) { SlotEvent::None => {} @@ -992,7 +993,15 @@ impl Pump { } } } - self.unbalanced = self.ring.both_eyes && self.balance.check(now, self.count); + if self.ring.both_eyes && self.balance.check(now, self.count) { + if relooks.look_again(&self.ring.off) { + self.unbalanced = true; + } else { + let line = unbalanced_line(&self.ring, &self.balance); + eprintln!("{line} (idle), on the same slots as before: kept, not looked for again"); + self.balance.give_up(); + } + } now - self.last_change <= LOST_AFTER && !self.unbalanced } } @@ -1021,6 +1030,8 @@ fn idle( ) -> Option { let mut cached: Option = None; let mut pump: Option = None; + // The rings looked for again because of unbalanced frames (Pump::poll) + let mut relooks = ring::Relooks::default(); let mut deferred: Vec = Vec::new(); let mut next_check = 0.0; let mut next_search = 0.0; @@ -1155,10 +1166,11 @@ fn idle( live.poll_valve(now); if live.on { if let Some(p) = pump.as_mut() { - if !p.poll(&b.arenas[p.ring.arena], now, args.swap, live) { + if !p.poll(&b.arenas[p.ring.arena], now, args.swap, live, &mut relooks) { if p.unbalanced { // The frames did not stop (no headset-off count), but what was learnt from the slow camera's - // may be off: the next lock starts a new wear, as after frames that stopped. + // may be off: the next lock starts a new wear unless the eyes look worn (new_wear_hint), as + // after frames that stopped. eprintln!("{} (idle); searching again", unbalanced_line(&p.ring, &p.balance)); } else { eprintln!("eye frames stopped (idle); searching again"); @@ -2336,8 +2348,10 @@ fn record( let mut reading = prox.read(); let mut valve_seen = 0u64; let mut relocks = 0; - // One camera's frames far slower than the other's (ring::FrameBalance, on `count`) + // One camera's frames far slower than the other's (ring::FrameBalance, on `count`), and the rings looked for again + // because of it (ring::Relooks) let mut balance = ring::FrameBalance::default(); + let mut relooks = ring::Relooks::default(); let mut abort = None; let mut pending_stop: Option = None; let reason: String = loop { @@ -2449,7 +2463,14 @@ fn record( } } - let unbalanced = ring.both_eyes && balance.check(now, count); + // Unbalanced on slots looked for again before (ring::Relooks): kept, said once + let mut unbalanced = ring.both_eyes && balance.check(now, count); + if unbalanced && !relooks.look_again(&ring.off) { + let line = unbalanced_line(&ring, &balance); + eprintln!("{line}, on the same slots as before: kept, not looked for again"); + balance.give_up(); + unbalanced = false; + } if now - last_change > LOST_AFTER || unbalanced { if unbalanced { eprintln!("{}; searching again ({busy_state} time keeps running)", unbalanced_line(&ring, &balance)); diff --git a/tools/eyecam/src/ring.rs b/tools/eyecam/src/ring.rs index a89d760..350594e 100644 --- a/tools/eyecam/src/ring.rs +++ b/tools/eyecam/src/ring.rs @@ -588,12 +588,18 @@ pub struct FrameBalance { since: Option, /// The last window's frames a second per camera. pub fps: [f64; 2], + /// No longer watching this ring (`give_up`). + off: bool, } impl FrameBalance { /// Each camera's frame count so far (`count`, by camera) at `now` (s). True once one camera has delivered under - /// UNBALANCED_SHARE of the other's frames, but some, for UNBALANCED_FOR_S (judged in windows of a second). + /// UNBALANCED_SHARE of the other's frames, but some, for UNBALANCED_FOR_S (judged in windows of a second). Always + /// false after `give_up`. pub fn check(&mut self, now: f64, count: [u64; 2]) -> bool { + if self.off { + return false; + } let Some((t0, c0)) = self.window else { self.window = Some((now, count)); return false; @@ -607,6 +613,38 @@ impl FrameBalance { } self.since.is_some_and(|t| now - t >= UNBALANCED_FOR_S) } + + /// Stop watching this ring: `check` is false from now on. + pub fn give_up(&mut self) { + self.off = true; + } +} + +/// The rings (their slots' starts, `Ring::off`) already looked for again because of unbalanced frames +/// (`FrameBalance`). A search that finds the same slots again, still unbalanced, would go round forever (a lock, 5 s, +/// a new search, 1-2 s of frames lost each time) on a headset whose camera really delivers that slowly: such a ring is +/// kept, and not looked for again. +#[derive(Clone, Debug, Default)] +pub struct Relooks { + seen: Vec>, +} + +/// At most this many rings are remembered (the oldest is forgotten). +const RELOOKS_KEPT: usize = 8; + +impl Relooks { + /// The ring with slots `off` has come unbalanced: whether to look for the ring again (the first time for these + /// slots; false when they were looked for again before, and the ring should be kept). + pub fn look_again(&mut self, off: &[usize]) -> bool { + if self.seen.iter().any(|s| s == off) { + return false; + } + if self.seen.len() == RELOOKS_KEPT { + self.seen.remove(0); + } + self.seen.push(off.to_vec()); + true + } } #[cfg(test)] @@ -1017,6 +1055,28 @@ mod tests { // (the window from 6 s on is mostly slow already, and counts from its start) let at = run(&mut b, 6.1, 10.0, 90.0, 22.5, &mut c).expect("looks again"); assert!(at > 10.99, "{at}"); + // Given up on (the same slots unbalanced again): never again, however slow + b.give_up(); + assert_eq!(run(&mut b, 20.0, 20.0, 90.0, 22.5, &mut c), None); + } + + #[test] + fn the_same_slots_unbalanced_again_are_kept() { + // A headset whose camera really delivers that slowly: the search finds the same ring every time. It is looked + // for again once, then kept (the lock, 5 s, a new search would otherwise go round forever). + let p = EXPECTED_PITCH; + let ring: Vec = (0..8).map(|k| 0x23_4100 + k * p + if k >= 4 { 64 } else { 0 }).collect(); + let moved: Vec = ring.iter().map(|o| o + 0x10_0000).collect(); + let mut relooks = Relooks::default(); + assert!(relooks.look_again(&ring)); + assert!(!relooks.look_again(&ring) && !relooks.look_again(&ring)); + // Another ring (the buffer moved) is looked for again once too, and the first is still kept + assert!(relooks.look_again(&moved) && !relooks.look_again(&moved) && !relooks.look_again(&ring)); + // Only the last RELOOKS_KEPT rings are remembered: the oldest (the first ring) is forgotten + for k in 1..RELOOKS_KEPT { + assert!(relooks.look_again(&[k])); + } + assert!(relooks.look_again(&ring) && !relooks.look_again(&ring)); } #[test] From ea93e8d1a683d461ec699720d52b083e164bbf69 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Sun, 25 Oct 2026 17:49:26 +0900 Subject: [PATCH 51/65] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B1=A5=E6=AD=B4:=20b?= =?UTF-8?q?eta.3=20=E3=81=AE=E7=AF=80=E3=81=AB=E3=80=81=E7=9B=AE=E3=81=AE?= =?UTF-8?q?=E3=82=AB=E3=83=A1=E3=83=A9=E3=81=AE=E3=81=A4=E3=81=8B=E3=81=BF?= =?UTF-8?q?=E6=96=B9=E3=81=AE=E5=81=8F=E3=82=8A=E3=81=AE=E7=9B=B4=E3=81=97?= =?UTF-8?q?=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 3 ++- CHANGELOG.md | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index dd5819b..71a4165 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -2,7 +2,7 @@ ## 0.8.2-beta.3 (2026-10-25) -パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。また、目のカメラの校正で見開くたびに上まぶたの高さが違って読まれる人も、見開きが届きやすくなりました(更新したらカメラの校正をやり直してね)。 +パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。また、目のカメラの校正で見開くたびに上まぶたの高さが違って読まれる人も、見開きが届きやすくなりました(更新したらカメラの校正をやり直してね)。HMD を外しているあいだに目のカメラのつかみ方が偏って、片目が遅くなったり、もう片方の目の映像が送られたりすることがあったのも直しました。 - 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(Google ドライブなどの共有リンクを非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります - 録画は `~/eyecam/contrib/` にたまります。学習に使えるものだけを移します: `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろい、両目のフレームがあり、目ごとの raw がちょうど `frames.csv` のその目の枚数ぶんあり(ディスクがいっぱいで途中で切れたものをはじく)、フレームの 9 割以上に Valve の目のトラッキングの値がある。CSV とファイルの大きさだけで確かめ、まとめるときにも確かめ直します。使えない・途中で止まったものはわけと一緒に `aborted/` に入れて、撮り直してもらいます。隣には、いちばん新しい校正結果・ユーザー校正(`calib.json`)・eyecam の設定の写しを置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません @@ -12,6 +12,7 @@ - eyecam: 新しい `protocol_contrib.txt`。`look_left`(長 2 回、高→低)と `look_right`(長 2 回、低→高)に専用のビープ(今までは細めと同じ音だった)。status.json に `headset_off_count`(eyecam-rec が起動してから、目の映像が止まるか近接センサーで 10 秒以上外していて、またかぶった回数。サスペンドも数える時計で測る)と `started_unix` を足しました - `frameeyeosc-panel --dump-png … --fake-contrib 状態` で、それぞれの画面を描けます - eyecam、見開き: かぶり校正の見開きの高さを、見開きの段ぜんぶの中央値ではなく、段ごとの中央値のうち一番低いものにしました(上まぶたの線が取れたフレームが 30 以上ある段だけ)。ある段だけ上まぶたが他よりかなり高く読まれると、見開きの幅がほかの段の見開きには大きすぎて、見開きが届きにくくなっていました。そういう録画(度付きインサート)では左目の幅が 0.52 から 0.25 になり、見開きの段で 0.5 以上が送られたのは 36〜39 % から 91 % になり、普段と細めの段で見開くこともありませんでした。開発者のかぶり校正 27 回では、幅は同じか少し小さくなるだけでした(中央値で 0.011)。`calib_result.json` に目ごとの `widen_step_levels`(見開きの段ごとの中央値)が増えました。効かせるには「目のカメラの校正(18秒)」をやり直してください +- eyecam、目のカメラ: HMD を外しているあいだに、eyecam がカメラのスロットの一部だけでつかむことがあり、片目が本来の 1/4〜3/4 の速さで読まれたり(右目が 90 fps のうち 22.5 fps のまま、校正や録画が通っていました)、片方のカメラのスロットだけのときは右目に左目の映像が送られたりしていました。開発者の HMD では 10 日で 138 回のつかみのうち 17 回で、どれも外しているときか、かぶったその瞬間でした。両方のカメラのスロットが 4 つずつそろったときだけつかみ、1 つのカメラのスロットを絵で両目に分けることはしなくなりました。念のため、片方のカメラの枚数がもう片方の半分未満のまま 5 秒続いたらつかみ直します(同じスロットでは 1 回だけ)。カメラが完全に止まったときは今までどおりです。 ## 0.8.2-beta.2 (2026-10-24) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7190e13..23b0d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## 0.8.2-beta.3 (2026-10-25) -New in the panel: "Record for squint training" (Advanced › Debug tools, once the eye cameras are set up) records your eyes for the developer with one press, to train the "Half-shut check (AI)" on more people's eyes. It is entirely optional: it says first what is recorded and how it is used, and records only after you agree. Widening is also easier to reach for people whose upper lid reads at a different height in each widen of the eye-camera calibration (calibrate the cameras again after updating). +New in the panel: "Record for squint training" (Advanced › Debug tools, once the eye cameras are set up) records your eyes for the developer with one press, to train the "Half-shut check (AI)" on more people's eyes. It is entirely optional: it says first what is recorded and how it is used, and records only after you agree. Widening is also easier to reach for people whose upper lid reads at a different height in each widen of the eye-camera calibration (calibrate the cameras again after updating). With the headset off, the eye cameras could also be locked onto only part of their slots, leaving one eye slow or sent the other eye's frames; that is fixed. -日本語: パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。また、目のカメラの校正で見開くたびに上まぶたの高さが違って読まれる人も、見開きが届きやすくなりました(更新したらカメラの校正をやり直してね)。 +日本語: パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。また、目のカメラの校正で見開くたびに上まぶたの高さが違って読まれる人も、見開きが届きやすくなりました(更新したらカメラの校正をやり直してね)。HMD を外しているあいだに目のカメラのつかみ方が偏って、片目が遅くなったり、もう片方の目の映像が送られたりすることがあったのも直しました。 - "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (a private share link from Google Drive or similar, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). - The recordings gather in `~/eyecam/contrib/`, each moved there once it can be used for training: `eye_L.raw`, `eye_R.raw`, `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` there, frames of both eyes, each raw file exactly as many frames as `frames.csv` lists for that eye (one cut short when the disk ran full is caught), and Valve's tracking values near at least 90 % of the frames; checked from the CSV files and file sizes alone, again when packing. One that can't be used, or stopped early, goes to `aborted/` with why, to be recorded again. Beside each go copies of the newest calibration result, the user calibration (`calib.json`) and eyecam's settings then. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. @@ -14,6 +14,7 @@ New in the panel: "Record for squint training" (Advanced › Debug tools, once t - eyecam: new `protocol_contrib.txt`; new beeps for `look_left` (2 long, high then low) and `look_right` (2 long, low then high), which had the squint's beep; status.json's new `headset_off_count` (the times the headset was taken off for 10 s or more and worn again since eyecam-rec started, by the eye video stopping or the proximity sensor, on a clock that counts suspend) and `started_unix`. - `frameeyeosc-panel --dump-png … --fake-contrib STATE` draws each of its screens. - eyecam, widening: the wear calibration's widen level is now the lowest of the widen steps' own medians (only steps with 30 or more frames with the lid line), not the median over all of them. When one widen step read the upper lid much higher than the others, the widen step came out too large for the widening the others reached, and widening was hard to reach: on one such recording (with prescription inserts) the left eye's step went from 0.52 to 0.25, and widening went out at 0.5 or more in 91 % of the widen frames instead of 36-39 %, while the normal and squint steps stayed unwidened; on the developer's 27 wear calibrations the step stayed the same or got slightly smaller (by 0.011 in the median). `calib_result.json` now also has each eye's `widen_step_levels` (each widen step's median). Run "Calibrate the cameras (18 s)" again for it to take effect. +- eyecam, eye cameras: with the headset off, eyecam could lock onto only part of the cameras' slots, so one eye was read at a quarter to three quarters of its frame rate (the right eye at 22.5 of 90 fps, through a calibration and a recording), or, with one camera's slots alone, the right eye was sent the left eye's frames. It happened in 17 of 138 locks over ten days on the developer's headset, always with the headset off or just put on. eyecam now locks only when each camera has all four of its slots, never splits one camera's slots into two eyes by their pictures, and, as a safety net, looks for the cameras again when one camera's frames come at under half the other's for 5 s (only once for the same slots). A camera that stops altogether is handled as before. ## 0.8.2-beta.2 (2026-10-24) From 2b6161dba86b9298204fdf810e577633ec66b291 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Mon, 26 Oct 2026 09:00:12 +0900 Subject: [PATCH 52/65] =?UTF-8?q?=E3=81=BE=E3=81=B6=E3=81=9F=E3=81=AE?= =?UTF-8?q?=E6=8B=92=E5=90=A6=E6=A8=A9=E3=81=AE=E9=87=8D=E3=81=BF=E3=82=92?= =?UTF-8?q?=E3=80=81=E7=99=BD=E3=81=84=E5=B8=AF=E3=83=BB=E6=9A=97=E3=81=95?= =?UTF-8?q?=E3=83=BB=E3=81=9A=E3=82=89=E3=81=97=E3=82=92=E6=8F=8F=E3=81=8D?= =?UTF-8?q?=E8=B6=B3=E3=81=97=E3=81=A6=E5=AD=A6=E7=BF=92=E3=81=97=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=81=9F=E3=82=82=E3=81=AE=E3=81=AB=E5=85=A5=E3=82=8C?= =?UTF-8?q?=E6=9B=BF=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前の重みは、レンズの白い帯や明るいところがある映像で閉じた目を「開き」と読み、閉じや細めを消していた。 ファイル名・ネットの形・前処理・規則は同じ。veto.rs のテストは新しい重みの値に直し、閉じと読ませる作り物のフレーム(暗いまぶたの線)を足す。 Co-Authored-By: Claude Opus 5.5 --- tools/eyecam/README.md | 7 ++++++- tools/eyecam/models/veto_S32c4x5.bin | Bin 369540 -> 369540 bytes tools/eyecam/src/veto.rs | 30 +++++++++++++++++---------- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index 492817d..c8cb613 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -853,7 +853,7 @@ lead_in・close・normal・widen)で wear 校正して、残りの段(step > **既定は切ってある**(settings.json の `lid_veto` を true にしたときだけ動く。パネルのまぶたタブの「半目の読み直し(AI)」、 つまり `set lid_veto on|off` で入れたり切ったりできる)。学習に使ったのは 1 人分の録画だけで、 -切り抜く位置もフレームの決まった場所なので、ほかの人の目でどう動くかはまだ確かめていない。 +切り抜く位置もフレームの決まった場所なので、ほかの人の目で確かめたのは、インサートを付けた人の 1 回ぶんの装着だけ。 - 回すのは、古典の EyeLid < 0.5 か閉じのフレームだけ。入力は正立したフレームの x 150〜360・y 40〜360 を 32×32 に平均したもの。 3×3 畳み込み 8 層(4〜32 チャネル)を 5 本、logit の平均 @@ -869,6 +869,11 @@ lead_in・close・normal・widen)で wear 校正して、残りの段(step > 古典が低かった 1 回だけ)。合図の 40 回も同じ - 取り消さなかったフレームの出力と、EyeWide・瞳はすべて、拒否権なしとバイト単位で同じ。校正の流し直しの結果も同じ - 重みはこの録画も含めて学習したもの。日ごとに抜いて学習した 5 本で同じ規則を当てると 3.0% / 1.4%、細め −0.1 pt で、ほぼ同じ +- 2026-10-26 に重みを入れ替えた(ファイル名・ネットの形・前処理・規則は同じ)。学習データは同じ開発者の録画に、白い帯・暗さ・ + ±10 px のずらしを描き足したもの。上の数字は入れ替える前の重みのもの + - 前の重みは、レンズの白い帯や明るいところがある映像で閉じを「開き」と読み、閉じや細めを消していた。インサートを付けた人の録画では + 閉じの 14%、細めの半分が消えた。新しい重みでは、帯 35% と暗さ+帯の映像でも、その録画でも、閉じと細めを消さない + - そのかわり、日ごとに抜いて評価すると、元の映像で誤った半目が 2.98% → 3.11% と少し増える ### 暗い映像(度付きレンズなど、`feat::Levels`、2026-10-18) diff --git a/tools/eyecam/models/veto_S32c4x5.bin b/tools/eyecam/models/veto_S32c4x5.bin index df02046a19102c3c502e6d3734459f40883e72ce..e68be32333a75555054d530e35ff72e3d3fa352a 100644 GIT binary patch literal 369540 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⪻{z5W6l4yUq}7o>@okT};d&}Ht1o?(7GdV)m=5oz-h`QbY53xo4_#luBe47z$l8lRRP7~tD(O7U zo<r7n2h!>qH4~)iGw%G|KJ|aFT8cxdi0@p6I>ge zMzt*VVA}6$Mw||@vr4AYgBx<_wtMkl-R6dgk0W7^Z6zLjSHlS3JHfafy@y}4RLOYz zb=1pR2Wv~js8RePFgfpy0ZX4SNz3LEXB)zvX!v$W`c(r3wrz+_M^P@&t* zDj5CnuQ+ovx73QXp;t7U_}`|)5$mnm=yIrsF`H3`UI*m)KMr-ULT9`1lEXz%J0}A+ zQF>6Ey^0Pzk7b{1IuD|i)3E;5do()7buv>bd7n(W*dlFxdZgL{S2yc}rr;@*_BjP} zj*YXKx~h!D9|?}B)4@E;BCkN3?%4sdE(PxHth*M=p2G2-ly0HIw|-%i=h9#-OL*QNEA94 z&fEN5oEmL?%2P|ci*s_HLz#*Rc{g_=ZgQI(rGE&Pv63%Yp5Yea9MVw4miJ ziai;XAZXPG{Dmy%uUk>GRrDkF%`sy&)|z0-weM(e6@{Ox27zd{V&&>$9Gi89FP`HL z3pK;oRZF+Ay=Rm-_UaA%Z|^G0dWIgP{6GFAmYR5Wq7h&oPqB+ju^in+c_Atq|SWZiZJ(U_w7jF5I5Cd!@1pf`KD z9F{u$e7y*q@?+rR@;dfMeF^^O_6G<2x}e&l5reEJb8f0=HrJnJ7VXo9_W$_!Hd28o zhH@Ff|LWnCR2=KMGy>Ju1+lr4&SUPAO|&QeHhXo`4$R%=#l}n*!1mf`67pRN2OHd| z*Jw4=-C9O2pPGUByUVdJ`8V#_F^XM-F=WU0R@n9OHadNcBSDL7(OFuM32B_kt3UCI z=}ugS2iJWEkDE2DvWh#LzV`qnuj){R%K~uq8^^Q^+0M9l*fFZL3S^9`(yocsjHj&v z#}ZYb~6Sw#sOums~j2 zy_8<#dnXas z^&Y1W)f}Nw!IZ`sKLts7O+uKzn2{dOdUZRJy$h|$*27I~)`1s{Ps0T!QR_PDx(%}3 zT|)H9+##$T)u3#h3SATC&w0y>h|ykYoE;^AXT?H6TCA0ElD4k-d^H1e;+LZ9!)5He z`yudqQ#WtdS9KInKg|x7CbJXrkD;H%L~y;o8=9n_G25DrnSPTr@HPqt@oxfD=8YIH zD!&q^W+gHAnxu#Ua~qR+8$X72!$Ng?`f+0f)Bd*ybJikWI3|hh z8C)+tQV^GSt)-(vM`7>he{iw%0oZ{r?^~}Tjr3a2&X<0P<+oyK7;iRhwzZ`0=O3c? z@MYe*?(?`WF#+xRM;Hh0%*nr#1>()8;XgMKC>mZ*eCQZcA8J7Dx%W=cvj`B=c42q* z=CSwQx1x4rAx}13h)P6;gNLUq$&}$(0dp#`Gjb~VDt>{aZkdSlwZ4FI?`bCE&Qg3X zJ&_zeIG3I3rbc6}W|OS;J5Zak6Mq*wW8zo{$k)5jkTDayWL1It?ro;!+OhCS;|Ctm zoXE-_?8D8merPSbh?@RJ{L~)+UREQ#e^O!eO5iC7_$@?Bqt8H|;7TlN(ty}fHNAul#dlA~AG(Py&~Q0vS|_E}5@jwNR>eRBR5Ns13K$yS8^6#P{qHf00*@rNN! zRXd55R))CwQ9rA4t_@WZJjko33yE47$1!@?i4j-Ls84e^s#R?yOD+Dur-Kh!i~QK? z4Eqe|Y?7uuL3Rcn8fy5{Zy^Qcyel1w^!d^;#`&lVQ!3^_KJ3e4em^tgGH_<(z-mRj zw!8$p&pl+jW3!ohmtL^Cv;}%+Nz&N?SK!2df|w~T!!!&fL5`w3qc%GkWIsgXgK59< z%}5W5eiy+3Gktn#WC{Da*Pg9vF2~kqOW=6xBTT=!mAr=u7$0&Ul`_pB%62|gd3%bv zDJcYfeRn|Vq6M$%-EH=JqYe&u%ag}zIfnhKN@i9G!1CX=r0$O|JJaqdyOg0=~)$TW7;r2iag9oByRkRSq zTlqrNw|$IE(JcC3qCJ}zfVgq*5;P5Z423P)WJ~7_*g8?4`x!N1+|CZBF8>amwz3#(hBCvcS z(=ccbJG-m#(zP<&uF3JKjxVH^-L9C@aRE|dw(??cq+#q92^#Hol|8gQjlCEk#4g>M z!CGdAGe55mK*sMJ&Oe$8p!uo5De_ZGOz zK7nb{&OpaM!&%NLw`KJc-m7vTZw(`u3Q0E%?7t81qaEQ@LW@eNLbusYrQ7pwf`Q&&eNux z>no9Z*L>pVcYZ)VHyfWqy%{Rzh@ZB!q0?F&R(8D{7^;Rdiw1gF--UDGz_n`n!CscV z(X7wAa$qApXlI3uQ|3TMvJDom6e6ysnV9%m2^tHuq4w@1ID0LKtlDqG9A`G7PwPVZ z_4FV7G9d{C%G{a5R8$DUoaomu;hY+MuJjW>eB226LYWZ1GmDK$ z9YMDtMY?C@e(?EGVE*5RN_^_a?Xe8EvXf-X0e_paPFZ2F=}iar)petI`DQdBsi=AB z8}HRK1?EWka;|Hv!G=8Q0P($V;EXKS@il%1p?9sBOXG9sf7aZ2ts2EV_Z?w27FfgF zMrC$o-#Q2q90jKdR@A!U3vMv(X7zbX$?Fkrf6}V}#~moTY+3~$4D@(bCl(WlnA^C2 z&t=%Z(25=;Q_0#xN>nWQDfmZ;G6!Y~(@cK{I8S5X#rYZRvE?UgvTRd%BPHUby=W@9 z?feMaG$cv-;BT~#mPa+UG@gleJZc==i#s{*$nQH=w7RSw=k0gH_2X$cY`F`IPjLM2 znpyC!s}Eui-Nfk|GU(n%4Onpg6wTUF&X=eXp~5fa(7@<7#~N=z_1miKy&cYs{ViXZ z@cBMy4mQKm+Y+Rq!yWoduAsrb6DXssLtlHFbA8}f=-w5Ko$5zWwv)@&x|#9=bz(s- zvV^e+Fvd?3F*tbr5@T^RgSlJk3pLq?p^}SS7%HgITie?){kk=tDnqDG(IfXl%3!wH zFc#l?%@hy1(Wz^**jTZfINNd`lR9S(od$cEn?6fXB}bITT%1HBt(B0KEO;;ZUgz~9OykgmrSm>%tkMcqW6;{sp- z^x`Pu)jf>sy{E!A-y!yiz;dEcPDp1)DS_~Mvgmpw!N7D-;&PnhN}GuHkqWjUz7irf zKY`3w7nvKL-mvU$1fIO!39p~ZkuUB~NMXc0vXB*|?+;9%3#Pe|o9oNK?8;g0YlCsc zT0O3Vxfbody3+V4X+k@+`68)S)PcE61a4*$$vsN2v_*_+N0l-+l-pQ~r_FF{Yc7l$ zjIq^@&0uf7jyx}U0%H45k=?T=q43Y0(Ci=&KjiK&9Q$tvDSU!NocTl4XVj9J;wN!I z!DXK3%oZFhFhd9N4IupK5AK{loh3O0k|xMJ>oj>{TB%f6_>e`m^Y`K&KU zo83`;R|y%ItW4Zeyx4n|=diAEGi343j7CoYSyGYu!o&~bqS4I zoP-7a+^jq_i|fcAqxKOundEsZVRuVD-_3hIxi;qvk#>vXSV*mm;sZgv(8J>#m~D9A zkR~~3v>b!YcQc8#Q^~%VM5w5)tJxf7OWy`-FgdUFnG;${RBDYOd-Fmi-Y5{@oQ1ha z=M=H#LdF2=zguWL=wh3>On~x03JzFp0sRSAp*eU4<6!fL5lIP!oy9Wbh-wuu&GKZr zTQ-DEE@ylfts|!AUcr^L1jaKV90WHVX56CH&^)99ceH%qqjTRD%Old=u1M?!F;e-t55$uoyev-Z2{vo9#{`k z&@cTsXeQld%#MBr<6v|4$HQOz7d`cGXo4;LJ`+l}w@c8yLe0!N`9!>Go(FnDEE{OJ zn0oH|3>z=m@*Y^fV=lK48Ge z(a5G8*c%cH%;W-?zPb-}>auV`FGG#@aPP#e^QhglF+3%z$hv(IRe6T5{;qSJ54ztzaZHHed?UQ(pqyb33tS0lTh z8AIamAJ&u?$G;md%fRQBpJ^0Du-q8S3uI-68%rBz;pJRAw z@oKtl&lqKtbLd>n6q3G9gG}DJoJf~uP%YPJknkSD7Oz8C`%|0>#eBn#YZAn8^f)P$ zIZ7hU*5dbecVV9&EhJuwT`fk+sQ~>mSY9%?g3o zlVSAF!$s7J>3~;Mf)oV%l6`*aM9*_R4j+4iiTyihk-!S7F#LdC-j;_Izhp^lo-Waz zH;)c~n*qmz!s)gp+tK9GG@RRX3@dz0pzK{5TKwG2gdbEywIhb)QMVrLwMk{}K98k0 zH=MxdR)qA9-@{XDxE{XqFW}u4#zh`a*b2TNtNm7&9A?5$o6Ch?mD-8N!q4Kb#&nvb zvXL2Ev>8=6XWYx--NY`GFjFIyXtPckcy_+TeaDr+x^)r-iyC&#lKkf3w!2ou}%=~xl9 z1nwyic#<*-8F}Z)CV5FxtelOrGlj?|vo6$YaOGW^I|Pbl)o5|U4rTV1a{C(_GG|{B z_NdfCxSTsv7VU{s&PY>Lj`3zR97V!LoA}kr2)4O1$oj=w;9`a|m7|4; zTzDz|=cdGBN&)vb_qk>o^Gxuj7+*%?|y=ql)V1)zc zJt%=mt*SK2F9TG{p74f#@1s7mxE}j@dF-eyVC$xopmOIs46f!_C-bV|%8?YdS2B^w zOMAk&yNPgKCP#)Ei~;UzVKbc9;}PZMRC19EUNv)uF2Pa^die^Ld7Omwd)ly~TMnL^ z>C$h8%KYWR-_YCJm3_NUn1<-Lv-6ik!cy@(w39QSU*|cJNAA7&@xXk#mF)zHMrjy} zo(#Jb^4RRLa3Y}0b>p|lV7E^h4326M$DLl3EsCaXS#IRqyIJJa`7G!@+lsS$UEsxs z8_>IOHcgX1NG!%RiA1Ur9W~>+#9nhyoO5=}T+@m+M}A<<$^llr#F(~Mi&Ddz32dRu z8R-0(!z_$2!=OlcLRK#((SOZq9CRGWZH__F(;tr?H*p?Oky?1r!Oa*J9cGSvnuM>P zjn}N-GlLD65vGbC8esjJL=-9208O@}f#_9@Ux>;a9_ z@}y*50~`!}i9AOiRG;NW%nWj&XLSnvTeS{S8DzGpDNxObg|K*I2))~I8z|c{NRV#)=*a22=2}i1m6ikgTw+fSo#j1dN-o- z=3`7}V?2Gz>N5FX`thgzcc!&E3uwqUcv|3zF&wKWi|g0C&FE*I=@v5iLG$2>Yz}^S z9!V$t=SHO-d+-&K<8XS%{0sA-gEG{l#EydjNnywA{*1Mr&F2|-?BS)tc zoq&_oulNbIveZ`oJLpbo=ZEr>d9~7mxYOe{%HPPx>aKH0lVjMAn^sV|s|1GpwqdGW zDW(W{frya=c>FoW>lnC#H4|r|kXjq;|MeKpdllfCCJ7>PAb{Ds;~=B-VkyZD223ws z2{ShMkWd8&l2@SwemCb31%514_LuVHeg&cEWqoRPYZtu95+r_8C$Q4d_IR-697Lw) zvS27p&iO^d{iXE~-aHTOqQ$X$OE2?wIrkaeQV4C8$FVv387rUg9(sSoTb!12!^@ej zAnM6=IjA(Q<8r<)bYhsF=iZ>1cN=rc)Q&&(x(3CIvK zf+QJ5tp6*G_Mu_S&G|lTa4N^;%KOAO-YU#GOUcsLA#*^b%Zb#^tzyO>4MY4#V}e^Z zk~GP$pf^E;oZU7PlAfr78l#2hj5eb4$Ui)jb00?Uni1>WPw~U7_YkGh3ig!&2f!$J=y)V{}o(=`}aw}gH;RL_q$Nrr^;@9@A;kwgBd?5ao>8bq#`D!y!{Lw0;88h)vJJ630 z5sd72!BCnbuCtxa=9DbM59)mw&2gn_mKI@9%^uKKcS7lHaXgj(=Hmo$7gXpoVg>@L z&}qYdG%&vkXPt5&<;*Sqt>VM1zrv21u#15(_sn)ImJlWH0)^;+M>p&7tPg&5U4h4W zqU`n7D8}K|AdG%WL?cgYkX_~gItJSG%j+1F)z+rFBu?Uxgfko0WQkYe&qKsW0`@)& zg|5<%e1Gv!XglIbb#MA(+;mCiz}F<~?h~Nfh4n!$=qiGq8m4PX5UZx^I6=T4w3f7> zWMl*zE-;f?RFq)h#Ax*G)W;0dnds#_AL`DS!V1|RyqO30qxjE3Ty`)6c*mFU=Pj3J z1W(jKTZ$Ce?s8=-+E0Up`DuD_-C_vVyp3C<6hTfa0c18>f@pIQ=&gui;vz~RL8}6u zq|RbH*2^NDnaDqp-GjG?H1+W;$5otPN_MR&Ri8DT9{rLGQw0U!m0>@eYgLDWgR{xO z+-GP##Icv_65-CL4EDB38@@XcK%F`7a=RvA~gIT?r^*f`$l6a|Ckz@+>6A;b$|Kt|E7`0 zvLO&CwTl^=!Lg&y*WzjUMa+$XT390$jT>fPg^D_9mdkx{9`Y!9&5LumKR!<#?i^wy zI%4orUO(?{V+$K()s01v$aHW=o1NfnvetPH$UJR?rB{3z-f6-%EffK0e!=zcSMe_K z^w^r@HpHRtjQI*j_P(G0px zg&6aziiuhs%4GR*ch%Yev>u<1-O2i7yZLtJkknDyJK~LlJ7qyXasaOD#N#1u-V*gU z3f-MJN7gkdayLkoU@PIz;5g)I0(x{o)I5BbD8Xyv@|PID6<6gxVU-W}flViO|5w-t zCrjUe>xD>gn%&BFR*9j07`J!e#PsqeYRLcUg`)Q@V8hoTESfJ#i#Q(2j}R#)rb3B2 z89P|$xGg23C*?_^&p%l8t%~u>od(5q{&@YBIoOLgvz=nI=;fqOQ2u5TEfI9FSULX} zSf!tZH0fkao^c49tA((WV|PeAI!Xt2reWJwMVNIVA6_o%VdNzx@v~z(+}U&-b+VoC z#r$jNdQS@RST1%X9H&cc8N3kQ#Uu=bfz#8M>`FID6n0didFdRREM1pu_H}2Lhd9$T zJ29&9<23X>h=KYDck06JVKN*NK=#0AXgB1MJ%*a>c#U_X0Vj3u&aGx!WGr$Uc ze{}jrvBfAI)}0n2f&us8orEUycCiKo>i=Xye`#Xrn^TOqU>Y=A2jMJr7rOrTN#=Fj zUo@Ow!Sz&UqvxABWNmK;WQ@t6{DpM7UOST)>HV9@9O!^Jo$YwV%WU$>^8irDCF_Xp? zP}K>SL02l8O?}hB^s9?eM)*EmJS0xE*Pp<35gfa=<1c!Mam@SiK_0*OI6G7LG0*yd zDDjKXf-?%r%qQLqj2bW|uOn7a^9wWS*I-dPT`_?NCcDVdPRe}zqYB@v^4Z<$qReUy zzzQ;*CO zlSw2;+Kb-0wwv-cEmpuA}$j<~4Ov4Kf6%9HaE>3(H* zZF8fGHl9N@UOGCGNRXW#iT=+uXq$yCm|QEsoSq7tW7P|LPpgpikzAH6E|^?BlTBihjnf@g5eUxyex#!SNB`Z;*?o*d8Q zk^~L9G{E*OvY@(+iZt)UEU**mWEX#V#^YPr&~57`^5kCng7;lLGG(16#7+aUZH@{S zxOQSd?tSEABN`}#vPRj`qB(;TJ0OVNbq=X~j%14$ousPbot3;Q}x`brHUaXv3;bDIzx2 ziF$21hIamSD5E7oF2Y>~0%{qf)V28aMHN~V=dd2S5m=ZQU6~p5_P;09yDsl!CU}KGOnL%tn!lX&oDl)iXC~vq>2WyQ zS%=HL`!Gi>SAlNHCtMZ$gF93IVV*4?o#NCX_)!Ah&7RNNF4g4Qh;D(U6O7O&dJb+V zn?S9mzX#c4yRgh(1bU=WQA;ueUOAoTxQvwDHvS*0HD8$S+PD^G|2)lVc$`IRg~h1c zup0EuMwuqeX2&!YL!cRG2K=wbZ;#rw06Se#*IJT>nU41$g9}GAX!|qJXRJg|b zd)5|zST&>hM{%ko&S!rwnaJ3+TFkJHuPT2bHTO>|1GZ61%4u_dJn; ze}dB(i7nsR>`r;|XVNBYE-pm=eo5MK$p$z0Rx=g6a~8yQ7`l?L8P#C00>-RP@nuZ>_;Xb9T|`#BmWJ=#p8CRkeKM!Vn6_=dhGkzypvSe384mjoFCVg|t?$Jt z-{2y1=F)ZeqUVMAhP(L6aW;^$tg1$LyA69*vI!b)+k(^82XGLED=&c9r;P z49-bI_eOOb&G?Qr%CksVu{t(x^QSf=mzXziD?kd4p-J*_Hont?iMX#r7mn&u{kR-9 z*X|0f*&WL)uz$v0hLqeOWa|92|o?xVN65)bO0`JN6 zcDR`rix*c(vL_tQfa$LP*og8S%-Y9%a9Upn&fyFlwy{FZz${)zpBR}baua`N%^`!S z?$G+*F~#Ypx;+v! zou9#6udQst_6>}+a1?Qtdc`{J`38U6``~1mDF5ye1L{#DMEdfdgW|enY`x}PoH+6T zS9M*6N-P+Ep4tUz3~oZxh;1zBtU%tG|o zYPz-|gXZRI(|?x)EmAIP(i@*LFj-cI3T<9QpK2|M&tS}>8UcvnsJ_|CL3GZDuA zZG-QeTjI~8KzMex2#a0jaQU`_>>aCpwDE=mhP2s|=oOV{msg94sw+w0#4B*_NDBJe z$&;#->C}^*PwalFlJBoKW7xMw7%UZLT=x>ZA2ERTMq{{2{SaHCAVSj3@8J9eJxuS- z=MY@(iiQQfq;KE$+R)!oaQ}T9eFpH=YA%5;cEn*YdHk9<3;GEn}=^hYj_tfw4p$O z2A%u41poC_VE>jz_K$Hrw1@`dT#gk}$lc5Px&Ol%o0O?!A4A7==ED2>22fhEn7jKX z;*x@`*p%xFo8Ot!+(L7Df@29da!jIwx^>J-g*8}cE`(nOuR&~eBvf^N1ci%%$pU?6vg^h@DbVt-()b2|Sx*s}#Wkzj3+3VUO3F-p?_QcYU;am5Kth3^B{dw(GY z>?{SpwFki5i{r#3yhis!3=FN}SnSDO%%IFsm{~p(#X*H)Cz zF@wZom2g--6Gkath z0)Hp>zFN993UwwPz-I-;sQU5}?%O|?uCY7BE7(NXJB~BRu9B%xGQ{m^bpz0Gw>E8# zI7($kKjG$IQ$R$slRfgx26u9tI@whN3uAm9GU;tD^iRk+R{CiN-cvn`S3+;Itw&z4 zF>92dU8xwP{&KvJlbcDFasv)3FQaAK%iwUo5$J!IKQwB^uAK+KLY46TmrtK1A4Uq{bJg(x0vsP@=F1?CV!ix$;VEY5E4Eih}fc zm^?mc)r6z(w&F@l5oV;)1I&L4k@ln?OuM!Xvu(wB`1DE)E4E$cEgC2I5WsLc?% z^11%mRw?4e{r#Mi5+^zuztP22ljE(MF=Y;u(aAI))-OBBHuq;UlDD(j_rIoshF&I9 z@0Nm^vyQRf?PQp5gIuQH;~l;iIl&{+6LH$j?I_+Jfa>khP_Tx<`TIOsSt~Q-|IWu< zve($D%V*I$YbMiMBY$y6vIyH)C1}=J z>&rT2aC1t4KUP!Wp)zF)JmcAW5yx<%L^w?K+KG3ZIUc3K5cHq6!CN{@a3N+uo8kd* z{1C|mm3(187hJ`@dI!4yZvbj>-l^S}-m;a+we0dzFY}fwf)MMI$Xm8>2(@k{^2W zAUjt=iY;idWni?Q^|4;g6b%`&-ImuuwJaFO^HO}0p9wvSEXfZLAxWbhC^63(#SVzrG zl|m?5DwNWa`Vy&B`aQpYfS2Q(^W69K`CRY!g*O54T=Op(QE7$&UB&3S`wtXrC6HAv zB5Zf29k=@ETF5kagf0Agc5B}~xK`qaj*HU4`%ePq$GpbZp8kR~eMO8Lf1T`?6Gev( z0{6qp1!Ye8 z>^QIiC-Hgz4<~g9nS5Uu{BX4}gzqjsJ{X7v3nOrAuq58`EW+u{bttawjJ{rKY+cV0 z{FY?Kb8KcnaFjHOO%R1^7bk=0x|8sB)=MaMDI&Gci?Fe9F_fe#lTe3Mu*E3=Yq!3} zLwSX`NYoKSa)c=VR0N`p4-tp!1XTa?1jjpfV3eaMTvgO({&ODE7vj!PT}VLrpDxPy zY^Ad=KBa>r3W#>eMM1;!1k`kHA){AsfVP=_P&0i2d9i0XX|vxR(J?o#qb9 zqI=4AZ43pHy&m1y4$yq9Nb)LI6-vhN{L)o>al@VKcqFe5R`1Dx)3!I@(G_EX-h-t? zb>xegH}p0DzSM%_+)FxN#vhfs55t#)L$Kn@1t=HWO1IpLp%XrAws|r-1utl+GP*Jp_sW-ewPPat)~Uy$N-FTcDH#^ra2l(BC&LZ#FLbNUQv7M6 z%*k3t3Rgn{TAjH>4F!o<6DrT2ISR^l`me-(*XbnbbGLBG`+TCFs0s_4o$2RZK9>?p zh3_X<@;qvJwq^9N(580@&NVedtIlxYlOOps#{Djx)B1^am`%aM{th@aHv~mTY{b25i9k6N9RCYUP0mRwFfc3e97`IE2d)m8Hb$KoZ)#IT118#7LAQU&Ud zD#vxx9C5-NNdhq?IGNL6AsX&jvG6KhZ&YL@APqZaMk72}iJkpBz^-KqF1dXNI~Ggx zePAV=`YayZPY2Uhxg{_u<^fr3AjgI@C$SHWKk%`o0}BYyVKz?oOzds}cpYg+zEsL8 zKEz{wsy$3yu?gzyUD+)T+a~z4Hy#%NCJcWMB|4i0A znaf=?2%rz8FAMs1m&5vGG1#lR5OQC%LSIq|{L!TOcXp=E`Cx zsIWCxwVB7NIqWfiM)r7gg1w87WV;skQSaSXQD31DcD()v+x98p&(<_xb^pL+0wty= zKhPONw)i>zJOrKk1c$ym34JSE=!&IJ1-_}#=pV{Ep*%!6({Zao;*++pBUGB3BvS#wq}2F0*;Y9W;QHR7Zs9ek}8Ob742AYw-%@oro=UHHTtYW3s67R;!|n<(hWP~>_% zmxFt>77ILenl1D>%JdrhP&y)sef=_(Sq=A=C>{MMFx>tVs@^T<4D45dTSpbjf3V}i zr{@UOd)hEy+dDY+iFYurYsU9H4{GBQHTKlZkWCXd;aGhc-Wm2C-=x0Bk-BFfe9T=z z)f+=J>T;$H;rjHM!x2~>@aqEmD|%t$;BbG!9GNh1lC=}kc6 zXT#KBNihz}JF2pOD%3{;bNLSYc-Lu6QaZ{MyRf&j z9a!}Ye>@p~3q=Z!^0VG(Dm66_4p?3jG+D}`)L}*DWcnQ!y6ok>pRKsvu?J^2l;Ur1 zAuPn1^vtwksyeBkX9hZvqB&XgXgSZ+k$*2-6@L$`PQ`$SjlEFUCkw5uj#QSccJ{&XJnQ0aw7j@4ZPtQYFll~VOGu))ID((SxYpgNCpeZGjTHeNh}rl zw~v@AiE*>W%hCh8Qc&~vM$Ee`z}~!0a$YMC)H*(q@iD!E_8;@W9)5#{aXS5C=7uGH zVwkYjovyGQg-#P+3A5*=5MTXcHrfxKlLhbf@bK^{Xm%?Be=&QeHlDvrD!c;ozO$H- z{|Y8}X>!?<^WaZd1Dw)7L7w$F;5Wj+S&xF4y$9YG)dUxdSulJ~kIm@H61(G~+PuJRHM`J5MJs+Y{SRGS3h-ht|1V_dT)z>yt>a*5PB=YqQWuWC5a*qY zrkHm>6E=4}<{jw?pnO1?cJm#ouBcAX2hSzt(>kz|Hlla0dSbt?63%=56R+s! zqN|%LBb_{3nHXVzOFX6C`sCH0`vP&zyEOIg5SbD7jZXUE1Tw}=L`G=avNW z(gh(aI|#RPKbd=75mP_5Q~P_Ow9=#sa$-L*T#hM*$>cOkp<^11g z$lSsiT;5p=`>GVVtHL1elj#yJV}B~Vvl}VAIC=u{E#=^+dJq(xi^o6TKNJ5TV~D$G zi?6%p;ladIy8G94pnucQ=u##XZ^_12-WM^cy$vVxKi@*r7M9(r6aIM_2|}JvcIZhq z$|bwAs52jN*zf5Yg-y(}O=DU3oY;5;V zyfsrBVvFYEkp@r9ebqwz0)0XGWF}-O7nfX0bsfI1TWmwotO*l7;pJf?O!N6rx;bLVR z6(o$rNtP?De2{;5%$o)8R}ik3!Zp8Qduwz~(O> z%Z0{Y!DpGhxZ?~%OFj!K+Jd+^OAh6~meQ|xMYz?)HITPzJgfR1gZEr(sn~p3*3hZX zwg{FmcEJpf#-^d=@mTs`Sv*E=`AfUYieTLi5w=B|=dsy^;l!%{=#>3qiDUg{D7b$Z zU&J{Qxu@aq***>Q(*f3xdP9=(bFp)}6D^r2gX1m)V)l3!n9%M^WA&Gk&Lz4wzJG^^ z6W?zibuI$k{gmkf!n2|}N25SQitTuP3l0XQ!_)iSbjq}1>>gUfCiwP~B>w*> z{M`dl>k?t)lps(R^@R5QhP1`(8QxvuNXu@hlkT~9sI|sn{MB#*=C6G%DCsoDEema6 zq;CP;`CMPH#%&j_vPi*eD@HK2AIi*buQXNaweernDztd>83Xcv;Leaq%;NWDe7|fF zPFT;sj!g_7nA|55FUWDvUPALa&mb2oOB<_z3d-?m2K6q*XD>o;?W54Oz=jHU`z1uz+Ac+#C^$hrj8wDbLMV*NJ+1vw92;W*3rUlCBuj za2h_`ozISSErRW9F5xdVU_bXcqhp?caxR`kL-IL#%}yY1tfaWXpJzxP?^F~w`^wJ@ z_y8~)VxQwh9zwh?OMqfi*qM@I+l zgu}Y0;Xqy!bX{$Lxae+j$MP=-mdv7`f9H`nvvF+U+&tWvt&H0g5+Hi(8%SAQ37Os1 zpwlJJsYzzQS2JzQiMoRh_iNx~(-jhvUn#)TTfmFI7cAW|5$s022b;>}FnRJ=knnKD zVt!uRH=vIHWu1kO&)xa^WCePjaE2TEUZTzKmr#>Gj?4BkVujyaxMjz+IPI|_Hj&3= zxNSE^^1m;dtE{wui0U+Wr8+sM~=#44S)G{}2a>9399 z0%xaMdi`c5_!BE=IJ6&XGe&aX%`V{5soiMo-$p*K5JH{1HFIjxVRtUSg5nod*wu27 z{o{9DEYp|ayF5RlwP7{8ncIf(TaIJi8at-7>NGxIzYMLnUWd5ATU5+c1YO?^;Jnfk z=>BvZ{+FbT$D&4J?7|deZAzHK#94Q6xFDf0h)EZ(hE38+oPnY*^GP)1CfiNso-V3^ zwQghJYx{XBbEFt&dK|)v4cA~BQAA(!4dC*n3YrEiFp|gs8E#Q!{16I6#M)lt2VY=%Hdc}M@@20hY-e?t+{`eFe<67xLKNUQ1!=F3fHwJB0 z$}q!wE4O@TGTRm~8N)WrL)FX??2C~tJN@@E4vFexWrrFI-LK8o*>_-#VGgc{^rz>x z=Tc$UKa4k(fXzO#+>}k3xM7SmE1Pu|xA;FJkn1yk5T zr_-(1HMr2g6C*J>CL~4BxaxKNBB^>wP8%s!Uy@=JrTR9UoOQ`5_z+Xjv{H}ivdDvr0osQlTdiS`Z zueB_*MNxL{q-N^Re=CjUoPAD^QiY~r2na&?@5frgyC@fxz1^muRdR8KCUo?PL4UO0}vYqs$ z@Hyk>Yl6DEGEg{r8{GViA@^H3UOJ-478X3fXML~nD6?VLj@hxU_-(=?w=>A_++kEV zK1vJ5e54ykSfRAjEb<~P0Nn<4aco@;t`?EUI5UcQm%`w{7kT_+>`x>@W$D2scQLxw z83L9UKt#I{r?cCeyc0>~-M$AP)G9?_QJsZ(z7c3P;DRd_^7GRx^B~H4A2}cIfQ3$z zX;W`)sg}G8wo6Y2ZtE#>YC$(@&E)5`^5?++!f(>FJCdBwe+$Pz*!VO=*~|ucxslw|OIzVYCjpIZN63_-Qy}xii3V46 zl8w1fp=Rt&3VCV*1t(Lk?e|~7yw|#LrY?-=Z@0yyJ3BETBA8U^@SO|)HahR~8U8+U z1H-aL!(^EdvfWLDoox{1LN_^b4O7O0YUE$g&{yNm=30;gbrtmE%M~Cjx57-3co@~v z1}#lVbpF%;_+9)0Cj5JWDaQ;+$)E?E^=PDFo|d3MM^Ls^9BvuhhgR+hxT(Yl4Q8*y zbAj=6N}CgiTV6w1epX}M@`wa;P3Xs!^4{VX#6Rmai3$y*i6NfY>)j~0bJ&EA)eXex zYcZIop+x#UZb759K04^!CiAHoblkj(cYgg6p7I#Sd~z+ZdrmIycgw)f%gS+Hf*j6y z6UrJYK7fwP2=4G3Ew=f-95**~0k^350iCBKjpe7NK&UIBXQd{>iS0_9qt;fN1QU1K z>aNX|w_Fqs>hSy$D1;Zq{;*RVX52@EeeYoPn^`#i=xLl^vXtxmFOhz*sHb_8B{(C$CM51vJjYKH4VSjy(}T85 z@?sc1a#qE9+p%n~o*Op)8N}-+ylBJtWTD-<-PDAibvdt`Ae8bsPD@Phl4egk7O*h` z6E=k+vTW?W7lG9RF?M{cHhbGI6uxasCDSh&V~=<$7}byGf|5t$%UlW0@p>C9-{?Z8 zgkK?btv^YMFcpd$s$eDWfQilvgH8UsK}2d5tO*#;W!>t82ToF8yx<%}KX&HIkI8e6 z{wHvYm^p~3h;lFN@=0aG9l>*97JQ8s1ew!muXf?0g0bt_+hh&rP5BxQ#vu6%L<7ng~#o0~aSy=OZ6*KCf~o32wI{#-BaEz4#5mD|WB9LKedcffK| zD#WJ^;ZVW=?2QhD>b0vecoRn_MVz5l8?!M%WDwiWETV#bKQw(}g=?;RfXs3&7+88s zFhYGQneS^N*j-`*Nki`N-@5|bxUdO-w58%kFLib|D1ds%OQ6~)FF1X~7p0y9g?QIV8DX2FuEB3RKpW6 zXx}Ktq&``({izqwVb56_qd5&c<|^SP&qGkxCxs46bLi2fM&!Y!%Q$hc8M{MD&?Zb3 z4;~kVr>atT{!$^1X)}d%;cQ45QeoN8y>QyCO}rn0--$3#fCZ}Ybba+N5?!MLlitFt3Yb(#19y?-Yr);kGy{Zz5{{8_#B|Eo4}I2B)vtNSbmA;9!0= z1l`WZPhZ8@1NnOVD)$&GK5b!3;`kYo`8k|(LkDkGM#5L=`xx@xiX)yCP%x8!4oXSU zl{e>u%FfX&1P-#0eY4rRWdVE_oMO3R7V-yPu>bZ2mh{Yn5F*3a`_b$S@6Po}7v)^u zA4Bf}Z#;gh0VWsCLo<&gcwV-b|GrR#UWp~d?C&574T~}JLLRCN+q0e}!Hj(uVfyM1 zu<2M6arD-Qvz4kW%P|-C?;bzZQ;yNK`7M#JH?wcxN*9fJQG!@e1g<6gjFuJ?m-wlI8lxFS%)CERuSGL+{GIeI|Yre*1_Y1J$Sxz z6sKVo&ShB-p;2q0O~cY1U~y>^);}1_PJNq$e!mBCwSok$p5}{>qWSN$)q!YS`h%|R zn##_tjl}ad44+!8#>#{8FwJTtm;8kib2C#M(OnARI@Wj+FH)ahe{h5sKuBI3boi{r zYN-v}f<$pvwaS7$?){IvTG=W*qwdXg_k1P0*RKHo_EG#@Ifr-rUc<3vWqh9MPt)9Z zzLuu~X&%_ma=iI&siguN+IAh6i{5~d5tF#v8yal9?CwCcvN)Ia*_BBhH)3YmQ&_vm zA?VEdN!srkgAVUX|8rH3>^&n2uMG*94{P9l2|lMUXb?2!t1{ux0}Q)2jvaif!y0Bq zV5;^HG9+0KTMo2BTX`!QW4h zz_kRVuXM+-RgMZwFH8f?XH{a*q8%(t=!KV5BjD?rI^pLj_k z=<<-yj%t-T?J2W}=c`1i!8gtxrm(Ogc5u@JSHFNqi;dP_ZzU!?gtoY{w*7~;wXFF(L(MQ z@247bC0K#MC+z6Z!Cm?J*cmj2*-TErmXklx|Go@!|5bqFBSK(_xC@?ao{rOHcVdOu zH0B~B#(EkHX_}Q28@!~>e*Y>5!LDE^J}=3?UtZ$5hGN(`us(iP;8^w>j|`7umpA`KI$o5iHSb_AWyeF%R3&&*f&%Bj znefwBQaBZPZ`tb#n0~JyUT+W{)w*?iTyQzruR@}vQ{Zw?z%!K48wmAJpIvbiV z4Q%g8VY^;Cl@dLLvz~0B&n~TVu(CPWmoLMLe~WO7=AOW-kDg%EA|+P3R-cQx9!wca!&Tg5vSYzdSl=}j z<`@}a<0(CM-Le~RY?{vA->Ju4dN%B|wG8`rU!1KQC&^4ix`ns*S#iIUj&TOZqM&W$ za=2fk%&1C4_PrhNZ2E=mHM}!sa}sQhtcN6@PmuolAx!r@&D~X5#vPe_2h^V( zE-O+|!Z*@l9OwK4q8AC6(OY}!Y;VE6a(oP-V^whL&LjA6@)+)m!(j}NTuEE`-sssK zCz#5G3fy)^41T<81W$7_3?C)MP7bKDk(a{RqplN7PDGP^7&(z}Fh?8yeYJ_HMZe>6EBY{<-^);YGoG7RzXSxoj^gHD zB6Mo#DYWYw%Yxc#$XTaRYvT2?a^THdq%pLQo<30;VNjgUQmXsPg&iVAh}v)_X$X#&HX5RojDS4>jQi*-tPk z{Swsw_&{PG_(H0LfJ+)J!Ho@0#{#nqS~xsJcO`{TUz2Fu`|KlG{q!x7Kl}y8u3H5@ z%4mI<>=8g(GR+v5f)5;hadfdWHOR~+zSWwTApA_Dp2gykps}3Me~TgSRRdbY)`Qr{ zdEDil-SqEh4Q@nYJp^19gAtt#(DBWK`!nr4wT*cKv2LYse*@31c3J{1OAbPl9M36~ znMW7zxPV3akr=3;0@ua=!8w^5Wc~PGU{`($mPXCQCyzzJs%|=We#%<%n0HYu`J7K9 zb#%e_q8)qa=g*ciTPTYBZ|40`n%F;kD*LncCFa=1(A~-_*@qocbjq;?QmWHW(l&pm z&0PeV8r*TaLM%34-G?`BeZ!sOXJg8;DP)~-AI6=CA<9QP@T-X-%R0bkSTV}nyu;(z z#F`bHhr1WI>Qy2pCR_k_%RcgFhFBRC){%>@rd-(p3oz*!AeUh@w`R&2I@RNaK&!W4SNM&Fd^q66?OP7 zkVwCUJI2NkteU|4UtaR_>`=I1VaP2#P$%>{H=W}Vp>WiCExt`S17ljXxWI5_ZcCjv zgc&A~-KjNTa6E-NirPa@;W*A)IfOfLs|>n2E)b*lA{=R4#_j$79!-t5!is7U?)IK^ zlt?>5RYwK$JB=C8vtN{JSeFZH%q6+Vo;Zkk*$I{LU!bJbkc-e7gBHzA_?Evbwg*(= z+Eqt!{iZ;)$mAJaFY^Re_M@mUd=F@fsB%u@qCk4k9(M=rI#dO= zlHugvrC2z@a!~h%AoY=-d1@yf+~fCeKjie*cu^?5h2UcS9i#7%fEm;Z%Whxda=hKa8_y zd7=Ip15RVdJ8I;gOkb7Df%sBatZyPzMJ)rJZOX{ZC^4Ki{RZxyF2m*gm1aLSM)SF; zHug`571(HP#`=U_y0*rMf1=&xckvdYn2!(Yue3tF$%};MG0Q=0*l#A~p8*dSd<4xm zKhan|1Fr=-qijkjdnoe>zb#wB-K`nRedv$k-mhN4kts1`13!!XJZA)?d#Q4bligwE z^BB78_c-zjPnQ~ulEB9%H|cEmC9pFgg}m_Ghr0dzz2i5-f2B!iU3DA%tVG#%%M{Rf zlt;f-i=*!17{Mp?9{ki}$nNgYWP%5pY;4*L7Bul6saG^7sFncP&x9D95rMut_RzDs zyYQcnIs56EhD-ltVXXaW!4w&PvVEc<+w$E89A!hm=Up$)h;SF$ z9thOJcG1)C+QHLT0srX8u`OT4@hGRw(kq?Vqrgmz;P2vH0Sj1i>kI_Xk0^-Dh40_B z`3~zC7?{`ru8Dsk>aH#~$4L#|{k?^%{~f{iJlEnxnFRN(L6`fmvj&bWa^}vT8OpV5Zb-nf7Ar`OMI-!BRfNHnqCDeRi+S)| z*HZLCsjHu`J58TCDeqwqj$NakF*Wpnn-7?8NyS4CR^wwI4S``d!?7NwtX1+KO?};s z#XL`rDug6a%w@#qW7QgYop!@V~&S##No{aA%WTN@wG|))74^n5lpnO#WRyM1% z$F9@B!v8zIJ)8={;xzbsU>9)jYe0Q(Kgnw4U5hEwOeIDJ<8kHT|KQKNP%1H7pB*0a1J{gqw3Ruqx05W^9)vDVrDJ@w55yoF|re_@b+Py)w zx=1>#6G$IjQpfG>Zlve8EK@zrYswcL#yK*Z@XmQDW^f&FYsztOZ8?kkCM4iLdIgG> z%7E?)1@1gm|+kEDnZM3(fI> zqz%5hm;)=gL_jSGW?FEb#2$DGtE;9%S-cqQ98_k04;`_jpaSxi>2f(OK{!q@ne(#S z&0YVpm&=~J7+fb^z{tmM=?cE5tJ1XD-Nfxr2TDBK;l>}@+$O~=5(djIZLNW46`EYwSW)h| zyc&~_|BXwPFJR6Z2e7Q}#N^&JypvUryE|bWUR(dVw0z!LNUIfvtXdgP@xUB*{BS;g zSz8%C7T|ekpxkP~k z&vVl}REwRal{oaY0gYRXfyn4`qxs(0^_DOU3pRl#B0QIW?n9m}+luoS-z3$yy};LY z9&DDXr|%OyY56!!v~_OAdwm(WsLceEKFE+RXInJ&n2LLJg|t4s03M!u31I=>g~O_E zLB>jv8}-zQd-c7Q^j}_wZo~X%_lY>yeD?;vsiioo{HyThrWxQK8c7WF_@3Q!OIA}C zO43fM(UW}vc+nvOlMnx*u?q+JJ)1#Paaw~Rfg;Q@VhIYzZiCOW`-rN*3FgRdLy=A= zuFl@XGK66?)5+XxC<`29_deFgaE z=w95X5RVsl*Zb8Mcd2Y$7K#?|z0kce7;++(zM1RKvj;rLONF^8)jW~UYeI4J$$VU2 zkV>6CS~A<^xj3e52&YHS#wov5z-rC`xF9{9?hu~Fcd`$mGx#<I>w&t{@zl0Jr* zsiV)o6KLxs4Nv`aP^Uf~0#-P1lf`O4l=gupD?x9!bHePPS714+gS4t;;$iIsSg_+V ztQ)w52Quyur@KE%!umb9XXqhLEwP6=U$S6B?QHCtJ;?7uo}{1nvr=M-DqGYc4e|Li zaPB!>7Hr##qaV!2{Mo0in^{4Gk zli0N6E|fnyk@Je3!j6v7X1X7L65mNhu=TE);Fh{5sj&G5rWbcnM=b%Fdz5!9Oi|*R zYcpnqQ@z6K;avuszznb0>e37t`75;*e?WgWI)k;EmBMG4s|ZGDT91$AS3Zg(hzj zxvo@j>Y)P`-@b?+9ABWu-Y2-W_6kb94#cw^ia1zx?yY9D>|wyNN|bgv>mK0!^P8n(yEA^_{=$&>?-HEI!6PkwEK4QFz*s1X%65D zy~)go_eP6VX|PIBA8NZrp5>psMxEC_2N5GlFfPl(J$lCM!=z`Z>4PZ8vrM*XKM+29 zv;eP3&4ZKP(xAZ|Ma|A%a3Lld6lSI4mN*Ft$CSYN;{_<$wH}Yjg$hpaJ`TV6vYdj) zYvIP(*~~WY10MaD1mu_!N)A`yjeH3{BX|J@-KUY8wE*)4mGJsD??16>!Y|OK=l)LOWg7r#qYJQcy%=3^rkm(Zh{K5NG@RB^NE7&OmQ-j54UL+H+0`7_ zi)g|Q>ka6%W+SSoUc-xbs_^1*ZS?fyvo(KFj2M-O%a{>MnHGxP>Q99;!VAe25W?`) zIQVopn+&gQf<%`+cz=XD=xhyymEr5*&%jQYp{)%WesA#WJqfliH5}#RenJ0>n;7t? z0OuCO(mBiO&X7H6f7c72{+kTi6AfXixE~Hq4#MF`0|@B5iIp;5^lR7z z`ef)89#-NRvhSVmwC!@*aR=Bf~5n{McZUL4ZQ-kHr zqoMGc4!FO&1iOr`&?~>sVrZ`x^ZN6T{@6gU_0MA5<+O~1wW||7zi~+SorOEyeQ?|` z4L{-)j1n1ydA@NtoGy*~0+D=E;rZ z{G``t)O&@>@BAwGoZmo3C0&7n{=?wC#{=8CtB4>_0sCb#;C^2s9=Yd^f~sh&eilla z@&RH37r?2b!SKLUo|}{Q1fnPD!j=;^Q1rrCJe)lOmnNQv)o~kP0-u|Ep*!@S{sf&3 z_HZgJm7G%DfP1HE3;Z1RqKL4Kp8YZ#N2+;a^vBIm=1=+O%2)XBcm(J^(_+i{|NTd; zk=%534fH>|3~uPW1NGUD;F;fa?&y{zP}$HZsO^mdSCJ+t)H6eC%`uShA3qzCD#A%~ z1;isSl@v7P@ws3&1agzWTBnNSlxuMge@j7pNhbYK{2z%Q9)oRT3;xD&sA zlI6?>nqbHKwPbqhLcykx7UAeumJl;?BKdE@G%)_|KQl|Vm-u=sa2EuRg<_UDWtln# z#MtK*k$?4xJiXBg3;WigYApq$sXytmbVCxmqUiJ*_D|`+kF4lqEoHBTM5mKWIMnJXr8d7U{}2 zf=B0!>4t~%;AiM#a%4{;=oh(>nK4=5d`N}oYWjm`Q~_L>dje+91Y$cY6xEMRW4Fz0 znWeD;^WNyfQjEv3UeyECf5TM4!0qMW)|v%R>z|Pw5x=pf?muXC|4eAwVKm+_Oghyz zlTzQUxW8A8rCfW9qUTQIf_`=8vezF2Jqpo4(~Z>p=OVm!d@pQyaZ)g>wUy4%yiQ|n zo(g4;z9(m1FUApk9#)w*h7|Aa6^c)&q|>a%P-ZFucV<=L-y(saMdKn($vOh#Z+lSl zi<;Q3{2Y}U-%$6|yKpdDl9Jo)$?xS`}98SoMeF!0ueT%`3CHLV+wQk<)YiE z4_F`IN7inZMU}`BB-LIR+^P-4H;`Jn8L6G3wI{C=}8F9 zu~ek5l-JYwE;rF-^aNJcs?HJ$zTiITF>E{U(AxTT8hLKL2fja1XYjkzl>|gfmdT$Bk5g~AV_k7&wI~TGxZU?jG_Ye~wKvzgiBvtdC;|pI~ zmLN0BuSGM7(GHyL!g*MC6gcf5T zgXwu|5}TC{@9kQlx3mJplRsl8KUtt2?pCBdoZi*g?)XTYipweXo| z2t2&96uKlIVdRGryv4=Y=t#W5H$hEU$20C?2YYdMz$LtSqJcUskH&_%cge|YSx%Av z+*ZZ+;I*^j~^TCmD{{3C@cK#VKPwb!uZypiP{E(T)!=p&0>^0bB zsV^_z1S`N{neZTm(z9H|f-T<%9im??6)s%Mcqu0m(rTa4zVBO3|kQEm~Gz&C2 z6~1Tig7>$r4@#jwM|$Dse+FRa)&fdh_o%9f4d`7L!lVkn)!rmP`+u#y8%#1Pgia#zs_=Y@RJ`a!8d7(l_4VkE|f&aXn;LasY*7g1)*_iGL z*J=e!RDBvZ7$D8fEHy;c?;|-SLs3qjP6qMiNwBcxH8H7D$FS46uxyt&r@3Gmb#$qx zvl7k$XKBiQM0XO64;eJ^#U1=yQ71Spng*q+_k}MT?!bm$7FLRfY*f6s4H;%0l!gRK`VLB$>u)e}QvB zIC}V<1G)Pi)Ju(!^1ZpV!1@YW&sE2A?I`?~@`Z+f{EFgFcha1R9r&PIlMMuC;>y@k zkV$?H^KF5d&CG<%}u@c;^hX`!kCB*Pe#;fFlqsTd7^_~A0i z3GD4}zQf;QkH75JveGzFwsp!evbVXOo-9cRyJlt3@v(wipH;ZMqW9o^-#g5TEdog! zLg-F0?rn_;^vWoLp3skPQ>}nxt!&}T4P&8q(P*yVcsQ(j_Z}yikLL6w?m>c`HJddh z9lqJz0YjcO9$Be^Y5$7ptLbWxJ=6wiyhq1v8Sf{XTS#j^dO`DY{$nquR(>05lNhL zashk!gYRvfmWDZ}vY|Gxzx0h(FxEjmc8pWP*A{=v9v?GgJ)WcRrhW}7jx+`-n>o~P z78ROaKa5+#^`QNEC_a0l&OF_;AsYK&h3{DSukjnv-8zm|Xq^}QX*T4*#vcy)szS@v z^I)#vMa!8L&adRN&@KOj0@rw=V!oZ5r7F!SH5qZj-sK=MD+9L4AA_jXkI6X2uY!OV z#+>)}Zdh%tAb7&x%YSC6a=im`f-_2uxUcpP4m@rr%F{D&rJE_+WV#ofWu&ll@ie^o zQHrgSNFf@wxkP1W@608KM&gy4FjRe=g>@$k(AIt#HeR@fDYJ^`$;f#y;ZY477_k|L z`Hr;Ua3?j%$P)H!4MNksU(uszGjmE)Mm>Ecc)YHH_Z-%tdN!Ly=6st&UKeG6qPikl!{7=l1fp?$SQ=8G8&}PB0T3hDH@7OrM^ugO&UT{ z%J2UDk3YPg`?=4#uFvQFt|m%#!Bq8k8k>5J4TOw;N*f>Ip37kOa<1Q=)q^l5vdJJM!b6G8OOPcjM+SAn43D-1?s`Ye+dR zNbGimvKdC$HD)B(i;m=OZk@%I?lt3j$Ox{Tn4wlrSl!qB<#KMh?sVr$3IUJ z@uN~C_@?MV_KpFXCUXQ#pcidljE8%8ut+dgu9U1Q>4d`A1n8G|Lz@y?K;~j# zjca%@&InJW+8ZZeV2TX)aPP2SU&eDX+uxkMS*%1?cns2g{YjwfZ$|$cAFBRh-U%ZCN$&y!#s6 zbm|4k+SdyDPJ7wr0wZ?kz&uQ~d^%?}k5%#^IT9pOH)9oxhW>Lp@XF(jsj@)p;b1 zO3_36XjK+aQSH)JDZ}hP*W%1yEAWz95*gx5VDtG~;Grl9>l^EY&OO$oE#L*zXa-@$ zqbEEcEE1P&UBY*y2C+{;j9VZk#_e6+4lhiqzS_xYt}|oFpB30#j~pcY5#`}aWptOe7vBE<2pXm?gzVRDVBUBIujwDd zIG)wv*djxFVs^lQMHpK6NTEiS5%Io}OUF&~f&KH4iY2cn)n6^S`2o`0rQdq^M7;y! zn?!^>Bnx-mdW{RGNwA~k(wtJ+Z*+1GK>AXf8&D~wn#&w9r|UW_$$Uei?M?8wL@JDW zz8hNfZ$n?(F&ygK&of{w$?GEx)qgLJ<_w-_bJ;VZKvPx%&&q3aMmK9~;$Ovgr z)lPx)hc!SWyO3u{UL{N0jbK0_mUJ!bCn4q8a6E1~-WJ^nl7~K^*^m_5xH1zXt7~vU zAMd!l{SuT4Oh}_$0A~HxVy&ldVN#44(;MZ@%$gbyM=P^K`}V`torlTM()FP8I+dIW z9HNW<8VH}yIEFPTW%#Zg=_P(|BgUC9kLwydkNYEb7xt2!1x_&EuMxu6jf3U2*21~l z{|K*YOk)1pr)Zf|G!ANLp+;O5PHYBrcqEPzizdVR`zADa^(v^ndY9HUh_TtXpJ1|4 zD_!a*#|7NILP~0tFn)U=$Zzz(_{XYjcS@&VP1qE4x@OIMmLy?x`cGW4aU)al$i)Gr zZ#2a}4<7W6W{P`rh{;M-cy3(6PENiDi*1tN(PkZ3RFF+qE?Wl0(d$9K-T=aOodWGW zKjE(NNxoZm7SvM5Favo_s7X`>??6%Zd*?|sXgr3a_}w$tJ&LgCvsmuMfy(=r9#A70jLbirE<~Z;9=NroOz7J3k-C~>P?JkrXrEys zq`J;$X4A}Se6P;|x#zK9%(Hz~RR0HyZoi|QwiRIFd5etQmn2YnI|U^z#Ic=kaw%F@ z)+`WzNZxM>hOXy^B;C_p80>liB*OinIrk=ZH;lxn0V6i=Nh{v@;KBlq|HO@7f1vG5 z4H{hf7UtC*5QKcoq>mTfrC}4Rp&%-rs4wv(DZ74?wy&w=cH=o>?k2-$ekP!=a~58c z7H63~!}zl7G{sd7cwK!pCf2>6*Tzl&pFf9znsMB?WxM!pu?!eL)BuAUkLimqr$|7A z2o8!$llz~Q(7xCf=1oz?qMQe01<$xJ6Z=SZn%d&t870*2{36&?xr&&UD#N^~(XcV? zweVKkU%};yHQ>CJcOGyyxNl7@{+TzDt6UHPKfO2_6=%+!((EJGy9SBd#3ABhD#6@r zrcqg!GMu{WInPk7q?6p0aas6W%-ER@#wnIC@Lh`;JU%QqVYd>kw?(13`zF*}+kx7H z+4$~#FyC)-*#^Y+8_rjVO zIcis;2j)@#$kw3+HAnIfpp)c9a^cSiw!!1SnzHvifAPw3;uik}zs4Ek_z4%Fj`uG_ zDY&Edq<(ntFbqF$ID&$EIb^rI7^aN%ME%TUc>P6zYfq3u>+8zs(lQz&Pm8gyJjYur z~#^z`ItbfyUa_jDO{F-(Qr#?K6XFCpI zr`di|v}iHUO{ft%ix|PJkV~}X6@{t%XLa%50-)L7FsjdtbzWGEKUHP$Ot~?eVk*Y= z__&~f+H*nh_eP@S=?AX0Ip`-BhL5IdqTTDeSgV$aUgiJsb+kr7m!|?ey^;t?4<}%f z^i{grW(&WsuEV@zF(`7OoA1B)u+O{dP{AS!&uglZk?J;NMw=;XlnW)%d7UsfWFI(d z#^DP6bHr@005*Pcgs7|zjN?6u_j}LqS(e>kno$70hb*}zt#X|A@4XnIbeazK#Nh)^ z{*0Rxhwr*|p-EmGeWr%u-nvBeFdjvHKYfSD-BQpY@c}*mGr*aPtlhUP|U^_-6HQ<++-Bhr>9bG$`G3?t?+%5Kt%xdo?ljrkXhQ1hb+WKCN@(4YW zIkJzuSm{h2olAyK50s&^JRi)pWuVEy8&8Sra)y1N278lDM|8THNhB|ri?743t(8DwCm#Em& z4K125FEa*~NLrB4P%E~a-y_yPdw}!L+y?8@?=bL&F?DIN;vMRWuvYRlo{6r7Y#Y&wM&9Bo`gt)nl$#6z#nfL3pn+{MH+T_9geBCp!h=?)E_)oB+`g z7vQ^{m2h|eT5A8|96dxn(4eFVcxUb>a$xa2+O$xY)k<~ZvEd73LZubjeqVxCTc4xi zp=dIy=>RcS*Tn+m@w_wl5jN?m5%al6s87%f3=&bnoP;)*<{U-k6DufZlL+fAjzfsu zX87m77Kh$+k*VuavHrm%*!M=eM#rVW|z@X?CV3*mV}5{g)hmZ~1}x91DR) zqmOuY`zY8t%)1>F)(QPOo9S=$IQX;u0a^}EC*mmxdx$%JZ<v|u7vsy*j?%-5#eUK`&>#swP+Xq0oxJnT5OOGB_ zTn4p0AK*8?<0#$h!7Ps5z(j3jENie4Y?Ellq}_7d$#1WqD{?jW!R;?R&Mb%ZzWHE! zY!cBa>jAl4jS%tN8;bbo=fZWfAUtyz12=2o<4GNYok;@ho@bB7Cq>xbSby65OB=t6 zNipwTr%}(-^bWD@&V))s-Yr14SVb-L6hipC=Jzsyq0rh z;}|d2)wztloi~v=Y%pgh$KS(vIjvB5W`5i}hCkaCQKa%6q^UyJHNHBf&AyjQvs+f{7{&|O_E1L_o^8eaz4~zH z`A7I^oh^68Vl1~m{5n}y_yJb`l;@IpE?wx`=kQyukj%b%4zi8JxyxeVxVNjENS9>M zIXZphjHNrGQh^sYcm?Z{{7ONOKMw6~{YZgS^oF zTn+e{3{X9G5<(m!=R_d32V*e>=}ch!tU_0ak4CGZVIVvKQy|z>LYe zPhydAx8eE$ci|SjotU2_2UB9dp|09%96eVP!^*eghIW28R4NPg-~3VDHw$G=Pr~4T z>ZH~F5Qf3hmI~qNxuBB|)$+tcf%&kfF0aYjUNZkAueg&m`M^G12GeHF||t=~M3_fwJ%_ zOnNjHhny@R!|Djw^yJdN=Wdds74<@6o+p<6eH@(;dyFqQjr5^uKq-8ss+MEum{BuiV50WU=Lb|nlA+1kXgBzQRu*IXI zN@Mw5I_A0?`8abNy?8HPU=|i6{H4kvb3Kn6+QzZF@8+QEy*hN%CU~j%BR-zogrWDX z*$MG?5bE?1N+q0F{WVXn{rVCv^V%q+Z^}vVC0Tgm=Y=M7RiXY@7e8ZEpw(4E(#ZEx zdi6)Z#(fLOoX9>KXZs4!25-l9un;;tTuluL)&sTUzRxDt-c ziy}wh4tcUwiEF!)4L2i;Ayp)Yjyk}fgBlgMvmp*#dh$9rP76TvoCz0pbR5?dK8y7x z+=GeD(%fi;PTXR*)@4m)CahR{5_;(_n4D5W)|OR5kW&hHm%G8|jvL??Va7Xsz6+j> zs>atp+Oavzh6P-^jqz>$c=?0~d$UrHgk)Az>DVQ(cTkJdb}@kJa6}Qw@!YmiZe&+? ztFZp98ux6|Y}&Iw9<6RTvc}d*jP3Y_*3W0NlF11ulvZRzx6@H&>RW+k@MQ2)I!dm) z<_Xj+obeI6P40R9p&I+L1^)7$WK~)?+IQvSn>$)4IAjT-4y*CqUk-8{uFzlnb7vj6 z4|4h6lRf3i68W8|PIV?Vt-S}+E8=l`l^NZBk-@h;Z^83}E9{Csg6FsKXMo!uah+{F zUD&u0t?yehhxx|1L&p--Ud%$hy*uIOA9;LpvY3|LGQnVvG8}$2gu^R%{soDuojpwV_SC6u)k3fX%{}L_buD+y2`N zjv3D8OxId*CmI~sH5bGcQOhwVt{&>8GPpl~lDXfpz3}m9D0g&rDR+6&I_TbP2bK?> zkuu&#nK}H8?5!M69b7*{b>g-^W~k%E7Eg5qeNz3eyx?ZuCvjqOoX$^S|FRa(){T!hI-=tI-c22h_{1A|*; zq4bIa_(31}9^6UfX2;?~*&LL;{}lUXW|FmCFQEC@8lg8Uf|L0vFzMqoaB@CRZ`WzC zwo@C>tbZYsk(Ff;B@r}GaFw=ydP$CNJVwSZ{zA6p+i`QPPvY`fc{uQ|4Nde!IoSa* zoEDyhett8!g@OR#hq?3lJk3v79}hTB)|F@a4Pc9*H}g&~U@l_Hj27(2Reu}MDj*GC zO>3lc4~GHiEF(dZUjf(cgcmw|uiHQc)(5Rk$UFvaruY8Z8U%V|7P9x$T#P7ags!dqWzye<%?&gm)7f zUdrFg*I=Reaw`uNuY9@N=qp8P$T6-Vk)1Sw+5I*1-eI zO@LVs36DgV3D12nrbeHO=-81)f>HJ@ z*6E|zf9?H96ko!oDQ%##JQscuIdIIkpl1u`;rciYIQ--)_V6>_x4UxC(@>PGGV~Re z6f6gbl_B=lXHjmGDOdl?oVHx}M=#%aNrxP$&@U$rCy498&TZ3R_*Vtb3(0|ziU}Mf zX>i9@eul=TSOIj$QmqS@p)up9;P%P@y1XL<-doiO98>Sp8}o``;qYM)d0zl$MBFjV ze~|c<7QnWO0zB8DhzU`%1v+=$la?QkiSgtT)UBS0^2^7u_Fx%yxBniUe?1YIydsmG zUJPx#Z)bWshwEGVYgRkDl9adU_|<78))*HFMh0J{agltlZsJY|Reemdcn59upCE`2 z&V;Eyrt$uxlVEW?nM=&EMj4?5d*jFRGcV3Ve(8g&dNa{Kd^Q*Kme0MUHK3GU8st7- zjCwZ;(Es^563KJLrTxscN*|}2!a?F|_WRf-O zHv9--i3*(2;W$i_I0>GIWx1|WYi^6ye-PNcnQl>%rKXcoA^w>p=O=W*fUny0(sm*I zeyPVg4ov~qzXvcO*9eWh_CVcFUC67KChb85Fw@R}TefF2%I-D6nma0B6J&~e|DSt3 z7>hkqzX?OAH1qUTU^90$!6C&}IC|)r&~&{3wC`M_v-w=UKunF5*!0o?-nS{6w-Cfb zCz6OHGdk)>16qgioz9d!IG&wCzvvNc?Y=*F%{divL5cP3Gi9?BC7DA;D>z*12fEjv z1)qBhXXf&~F^d!u!mr~_`=`M6H>*hZgztieLo?unffs)c$|F}JUV@Q?B)%VQ3tRNf zgpWR%gLvm-m>$>&JMBB+^7m*6zxassEi-{zVhry$mtnE|8gih$Pmrk*Ov`FF(%m`h zG3IeRrj$0pn=7BOe98yhYIFxwGtAL?qZ6B(eGWXq+Xeg zSYZ7WgM@ru{df-AwAs?acd|^wz<~=&Tnp>`rec}p6sG9%7%xb7qNJw>n}3NvM}7K^ z9xDF$jGqk_HwK~;?;@BMTL7m+8sY8xGc+iu9BK})2gim^+LK%fl?Fme&Kkkc!H>k~ zstU~8QU@JwTOnZ0C&7w_ckndk0a$spLF6oby2MkHtJtT_{aSDXUzbXP+RsaH`u%P6 z9KM9|bAy4OcnFL-vfx>YF<3U8<(togMD?8qOlkQJiU0kBO0WHbHyh4i$z^ZWL&q_d zncl2zoEFCU3GJ_uw2^9AS6EQE_{-rVxTuLRpHywPahZ^+W=CvVOwpt6l0W*)h$AI_}jBjv8N2^hw$@?63Mi$W3zfPm4 z#8xPK&`j@#jA1f7$7pHyGu~Hqf(*UVW2vWnnRtUXIzPRSBhRd6pl-+F0tLb)-5n2cTT&*#r*@)Y5K$LSf$;Ro+HL2f;sa)Ev2=0TnIJbj5 z0%vsxA{p$@CGSuV=X7i9d?TyD^_FZcKPy1tKmffWoZ;>`)WJ zjJNv*^Dg*eQkf|(mVQFU-qC^gH!ea)cmOy?XVDA4e$&-gjhXzl+3bhDIaAxu&$W^q znB_}F&iU0NY@cS!dYW%yu~ao!N3;o?^Cie}w>wb7yAONSN~qHFMK~$G7+=^i>aQIP zf@`Ln%aUZA;CvA;zEtCWz8=Gl+RY*3z2oVTO;-fEFg|drFP*!ZJmaX-g$G!%A!Fdxb=!HA-)MPK8!w~t4hQc${->H|* z*|bE+X7qwu$^~+u?qKTjxGz5`cuU2*ZLl}6p57X0@5C|X(F$H4Q-!VhE2`Of13!G?-&pe`~Uj{Bv; zue38H{B;g7cyEI5oo`cdFIl$p)geeQe*lJtlHA(n3poB}u#0e=Jr~M%o2=zTSm)A% z;PJQ$=H{$|F`xg?jONd%8ZO1|HEf`cGkCX{jwo(wZ~|XG>t5d@ZUA1z1Z>2jI~Xu#04*|;idvzretS^`vn4UdbNzJ z*#7~Ksg+~+{<~mzZ!9-nAr7$hlrUOzGPhVYA12msW-%*{@fTSrSAPFqiY=XpF3*k=yp8?g@BsXW&($2ysRE8B}W!OVH z(B3W#R2;{4tg&M8ySCwkhst!lWGyxMo`t#CA?lx^EkkuEZ6a5H+X+`jP8+mx&e&luTzwg8k6pR}R@= z8eM-=e7Vh+rN1L=LHiQ6DC0kDaCk&s*t8IlpC;@Ae`o3}GK7<6c3k!M8rU0O4EOI| z2kAuxFqF27_KS|Ru;go#1d2J?yme|Ac(kVPUVGmTAiot+iC+zp)T}Cq+g({&U0!d#Rx{bfj3TLjR zLpeI|U*b&+cXPstKQi#I?0a;xvZ5MJjW|K#3m!xf?$Nq=U{HREO04a|@mqpWHhCG@ zsGU`lxc46wGP4P0b4UB(!ylmF-sh*>x3gtCNM76hxi#q zvp1&`XzBxo(E~Hso!pJgrdpqctl)Wm4}?UfcqPg#AHe^JB-gdU0ED8aaE|8=mNZKQ z^HL6@hK?uHeZB;H78HRJu@i)CQ{cA#65(EdQRAHLCel7tWjvUu#T`6ogfw3f*LyAo z&yAj-W8i_`wcn7p{N30p%$a*-`2|}CKa>8C?eIh>gHN7Rp!pjc4AgOkzduK?(-WgG zF!BmL$Dadw6zZ@_Bn>+&9hr?%96p}!1nOh+AyeI*i2L}$gcG_D^YA%+c7HRF0IGsNHL84Smtg1Sq)YefDwLHU?QsGOOBUp$mB{XaXRlbTA? z>c_C(%ca?_d$!!Q&_vum`3DZ%Z6tZjSnyTh6?K}C4O_0>gdMUyXtOXF#!fco7F<_= zh_fGY+l}M=Y%CvLJ7phai-GwP?>uIU(YH+>6OV~-tkUQ zUhq=r>0!Zp$G1aJ*aLEA#$n_e-Y(E(0#X)zXg}pKx|~fW4X3m48Tp3}E$8sjeBRxb zDlgb_U?F&?1DZE?p>CQJ+&zBOypSz892Ag8@!hC zOzV$rbPUfK|9M87X(dV3+=zQYJS}E`MV+MZaQRrcATkA0^TO@cjF2b7P5(hSNrGUX9ph^YI+teoAYqpu25)m+y-WD z^We(M3xeua8(3?s%C)K3!8s^_$iBSl3kzxlVf>slLEMqs?Ip*-%>DG`Rax%i+g;ol z-n+I*GaO&_9mCIj4#w@?5Z-C&ryq|lA!n|Mv0tPB=Ss$qV{gToXUj)8Y92(^gorSh ze*y0r7Qy9b$EZqW0&2dj$2|tAbnb3ZBGk#i8+^V@dGIBz_?82vyA!cCNS|6}d4sTS z4k+9k4P1*X{+FFbtVNPxubw+S;Ks8A62-WyTO?uQN^h?5)F)CBrH8GTa){OFi*RxN zJiMnT120SqVAJfog7X_Vus@Q3Q)bFCrxAHzz3UN5?wtd|wo1V=m-%G-$;o7tTMGW% zXO2m?)}lg_kVx)02zvFG@KG=Cjg)yu#_UXmH}h4QsoOWaL;3sv-#DmTau9cFDdC-O z13<=472a}l!Cd=`g0tI3uy)=d)LeVSW!olW_K=OEtE{J?D*1uhVt0AQS|-Tz^A$ae zC%==*iMzBAbjFO}q&MktPNmIcNrM3Qhm^wQqDVYdX@*mWkDwvRrUtLi<3&p)be8*p zqDw0=PUkZ0%y&T;XC}xTJ52M$p$soO3iW@$p1*#iVAV*UhhFX;9!OPva=;BRs z*Y7DA-rRy4LMXhE+YW)BuR-T>J|lPOIyqLAOQcNO@SV#f{!HeMrF-VEkYNqxHMk6? zc}5A}8l52LB~7qHyb{CZPq>Jlxmxq-O$65Wx={a#{{-HpLeR}hz_OeG2#?c(`svc3 z-FAmu--K}b(h~^#tBzZJoH6k2PJFc|2&0DY;?2KqWXB%ff3^1$j@|YIx1T76wYvi` zf`7K(DK!h-!lSV8#B^3~brCxfui&%P5=?xo0h?~$rsWemX_Is_*j*RW)s^wM%_$7T zA510lDDPZ}{X}K^GzBZS@uucH5pWIIK?EZ=V-Q5cYiWM2zMFyfV>R~5?JWAaI^l#4d6Xi~khnz87WH<`A*!f7B_U?vCC!QhCi^7T#xIDzviG;1ufyK&Rk8SjuxGyp_%imYz=FHE$N&J6Tokb6UFaSb8%#(Jsn0 z{ko5;IrVhMB3GI^TS#B5{05d8YS0s7058|wg~^41+^M7CIPKpoeDj_ob^7JBY|KBp zdGazWP71{hyTsYitvbwSvWakqPB|U?s)=SKn&+lnrxj+S@%0UJwzBdK&6&=Kw(VN5 zB!#5yoF07>cN3>+l=8c=DqKAEAu^jb6z_?|qr)XNy*$5lbk=KZo%<8B4(7w1^}W!$ zr~)sq=5wGWGr8hdo@l7$i7h`h!SP)x9STVodZ-AY!afkros+;%J4bPb17qpvp69~3 zP3s`$Z6?U*ECxHxOgQRf35TxUfg=)*)emp|BWlj>`1rX3K8Tx5GVbJ%6_0FjziJZT zj%*w!IEx2Od!c*BJ^UCdh1<6%a+$sVp}glwZ2HuP{@?A{yxH@Z`maF@oHT?l3~yqq z{sq)ElwvX?576Du%rS2I51im=%+$jtap$%FKcYsUQrw;HY(O_RI8l?0hTZn8 zwOovq33QlM|6(@P&Vwm^_XV|>Y;wU~1SDkKxC6F9!o|rA^nzOlq@Jju9!Dc_?=?|~ z%Q#N|^6#rkD`i$OQ;J<(smWC5sk3I@5nMUx4-QNW10}C)x<%`waJ}yqJT4Okwo7C2 zgh~=DDRlsGx8S@OW3iL#NyWKQs6o~cntClyiZx-T6wA{AI* znjAaK_W<2jUL^ATcZJeC2kagUpvn(V!$SUTxFfK$My;!w2n2E%e(f!8SzSdHmVe+G z!cTCyIL{VS|3Lar1%cgUE%IadG{3vb#*+D>c>KW+Y$eY4p;?l`KOL|^zd`y7^p zjbUFtO`G_o`WKmfK!Q!;_vN1_?-z)5+=AhS{JvaRf@L>jfX$jiO}|{ng!5lfx&0t6 zesLH-Y<9rxwk72FK{2ped;^ZxN%5@KVLaKi8=B;Fu+k<8zi!uu1KVam5Wge4VeAci zkBxw;-S-6JZ0112b0x_7Dn><9c(?LJbV5ec8@(v7y3o9`s+Vw#-|@-#=_;`no%w+m+mLSclY3_#n166|9(&8xvd^5 z2l2y-pBS4EOGZ(BekN?jf_hrfWzI9$p-bVm%Tl&rPB&D^=yTta51~_j4B2S-nNE(2 zgnK^kVS-cv9Im&d`y=l|P5Lp2bWeaX4?ZhAJqq@i93xiV4gASei~9j$+)T-2u-kr& ziVoZ&uWU7N{i4y>q*@7ye@q~A{#x9cZwA5pyIgka&Zje%eJ0OB`Rrq7Aqkw&2Vwz0 zzv%eloIS75r206|fC$7764Gp)>|RtEXV3E-%aP>>@SAuNF8+2Nopek|^e<&HP`!jZ zBZ!3h`H$(mb^qa(91FJHr3>3EbeZM7actlGcwwWf5*y$896i==M0we(_|(Z2p8XmR zeklvcwK;~|FQq=PpNROvNEwfsd%&qtx8eEzOp^VemN@>r1HX(8!2K)dh~^d@(&qJz z*crEjp7a#_#oy6ql%4=Q*Mp+n|M>FVdm(3DgZ;Dp;h2h)@P$Ew%Ng~>@TzVO4fEmi zw_?>~Z($0&UgOL=;}63ENj^t1+ll?oypJ#P>hYoCTO{^V*r)EL>}*UD_3tYoi*+7D z!X!0r{?hlLlI4$XVV2xv?e`?9rw$F{)H#dX1z;iFDzwRJL&rP0SgGrXFCriEIme0E z7%9aLx5bj2kP`xh3<24)=?#6<6$RwlZ<4;i6XvUFzyjs{d|s;olMVJ`^12qhWc8F- zU(KaVb{hKYCea7|XNWrK9viCTA-wZ5sMYNe>`T*2ta(36P$%;hs+t<8-C|`Fs#cRLGQXi`>g=G zmnw1G=tVnH=E7ig6?`v@f+=)0R5@J{oUHZ1VqXF6jIz zB$cytx#5U|g1jazFp$`d8cHaP@vS3E&s-F2y*w5}cK)QlBR$~7hYYy&<^g0F=EJxY zE%w{{1C)$c=c3A$VZ4_XXQD5~`5so~R&2c~{1k1Dmg0q2nRN)>Om^TdROX>v?L6E( zQ2@1u4s6U^Z~Cq=5SD*Vz~@Ro$d{i5FhTLSQ1jv&(mHE7=Uh0J{rFXY&hE~zVkINXxk z0e6mO2_*~*X}Rf@ z3vl?T9h2aD^5fP9bKmvVsO;?wYS}cEgf}e{SY#Q(`QSkQOc(}3QNM_$TrD)~9zpfQ z2{cn97b1-W@bY{EoIEMYjnH`nqQ)}ZINlc}wJ(Ecj68_)@vHIcu`7I!b5PimXFsWC7<5JnV09`o)hw{xlfh*@iLQq-fji~dt345E-_|uWkJnMUm5l; zO^JQam170vzU*6KGUDZGy5g`LJ5v7`@@L9&t*IBN>&;iN)%GyNjd_ImyK=yFV;6XN z=z!YugCL>l4K0r@gT}Qc_`-R?0W~pB!{jFn3Myb{@J_+dBunn-lLOHBIG)99Fo8wZ zA7O;*YvJ&7Bdi=eEj+S9jJ$NOhQPrWf(vOC;E;G&n0~-57(>*<)o;IuJ4q2X5ybW zS8V0jYOC$cq|A)zZ2v1*R+dWYydF{O3#nK%R~AarYoRGL8y4S5TzWC=va`cha?fzk`!@LAPvF~Zxk+ubz_LT{)GcojVUm?&x=?br;t+?W1Z=wGL545~hAn=%83mb!~P=CcA zB00{RJLPi?K9x;mwhor8JXxKsn|lmKma1@Py$^7{#$lYkY9Y5WK8`cCSLGVhPC_(4 z%kRg1-0AJ6vuzDEt8E-I(uuA3owWhYE@vIhMpcfn=jZ>XMJg+Kp0 zMH71tu&Xon^M7v&GjV(gi@X1l-vbnXqBaaHxdIn%NO3>K1DWKk7}orL4lC|a;B1SX z2|Xo>_v=={t1F|K`9P6iE?Emf=34Zd6yK%fom_fbZs62-Qx@Rg(# zOAAl~o7)YLxsvafoH$G_&9>og{7~m^@*V!rq$*IF9twu5CApB`Quv-<%;!HuxeG2g zVB+39eEK~By)4AJ%JWWa$W4YhZIpqjd*-t>%_6KJzYJG@v0zb06`B5l0+f#`!H*Lj zKp%g9RQK^=hM%ITwlrk;#qJZFAZ@fdb;<3fCp%lnWkI|Snb+i1D{L43E6zh~v^ zvl&?<+2uQ}e4<Yf~7UEdD+x*`sesVFQkH5XLh`-trsm*LwcdA6rOnki*gbv!Hs?e0o_>e=N9x%kPWh+?CCn>h8X9TmgYQ?)}MzCzI z97)L*I{zWhXb;uq^zw#CLUk;87&4uk_s5C31oCXJxC?M~p9wK5Q3j(yo&)voDEccM zL&aMuIRE=aoTi<|cIJd(pP~?aT>42OQQ%e&-w~Lpey0tiZr~Av|Dew2Kb*&Be4}hH z(3)opk^S_biaZ1Q?$;E&_u(^*h^hrKBSW~Idy$SEHH^Aa12k}L7kxw?L)TC(#7!=T z>t73CzJ(08^7}A2&Y8<84%wsfbqjR-b_UM<9wI5xG3e_0nFjn*;*!eNLGr#Dq3_K$ z2#m3Yuda${AM%fU36zB{D_u6~Hphyz>qu#2HGL}a3YXloC-2lPS$misvsB-~R_~Bw z!Aose*scuR7j6hJJp`QQwh+;0`PBNx7%&Q9Xk&C4R7N;r>gG#iw#sUdaaaca*~YDWCn}g7k)drPzj!fQR61OiXl>7Na73ZdpVHPuku-`KQ#z$MRz?pX%`e%FY~l*$pcv8*$I~@ocz!O&TA!jojzw-Tnsk;L*jilgAg~(^Fqa`DjJ%mPM2> zL0c9#)I`JXuNO&n&ll?R z2}SsalcEa@b};o>eUvPagn_g;klIuuxYQblP3;cCU9)bW+DUDktl-TSi_5Zv;Z983 z?vELMCB#2yP@uqP`{G$Y{S~(rK02h5Pp|DTZ~8m@RKPoT&x)`{ML*m+vz?au*y6ec ztLW4TgTj56G&uh{o)ac=4UCm7nREC{V!TZSYwP47;_^d=fI&Khmr{sg#EJI9qkxCLuOkjG?1`@in zn`k>0fU({=q3%T_H&)*gCW=VFrJ?b#p+tu$8AWmrhIltk)G^wxu@Xc-Ht?Ux7*=;{ z5;laD^A4@ESmicAR20f_Y_lZZNwJ1GZxbOU^&>fQ#hrOCpNlfmE9r}>_4HxNCDI)B zN!Y%x7s8&{3dgU}0+U0F*ib<|%1P#;xY|N2%IhP}XA4oDj^GBzM&ssbBA|C~FOg7^ zXGa=i(Rs5J;d#*4jOR@D$987-q7*78* z;j)@zKrhxraI)$eHM=Rsi58sUS;%JW$w~*bIsS`|x;K`cpR35SMyCu$()V>MM&gRo*idXZr*dOMkmK{oW1j8y)d<#1Xn6 zNn!jI33j@I=M|eR#nVoCHAd}{?An(Ocq4U~Z1MjL+xPe4D3w||)nA4g)!Ze! z_Zo4h0uF(?=OOZ1*PRn@Q0Jyw>2tyJ3rSwqI`*@+09`Am!tDw5^j3Ek{xBdIwR}5l zY1&^sy*3A&Hx=S$<-hp!>tj52&5_?BCNjk%8W{N7hE=#874GM|(ql&aB9eU#{(?bMA9r*Y&<$ujkWmOBNS;=QCIS zApkdwl&0P1mNApTBVfzz^U%_k!tZF320H^e{?^A>d~S6Ks?S^izWzSn)H4Te$@$jo ze6o+%d0WbheW#+GufUfVq8`3EhtPY|4*pfOj>tarKQvOmfkl%p@zS<3TtuWg&QR^+ zE0R}Wg}M=b7tXbhpVo!uvr|D#e~ zb)pijs7m2eClqrJr=)P+(QdGQs5#!Lc7v7n!aDTvG{0k#G98HvrWmQWsN8CUS7SR+ zEa8OF-7;L!ux=QUD9xtiRbl%bz+jJ8m{J)Ij2DYCyY+(4)-i??PL1&L za3;k6xx=kl`=mBM)0~W_@1vJj4nVj1UA#5&OO1|42<^G9k3ko{z@5K0(0a>h&S9Jk zJFA+6bHs-Pp8yYumLp)N(2YA8rwo3{+H8g7YWAYWh{a`2V*2ik-Ey;KKOB|t@4bB9 zw7Ud5Bi7@$HRJh5^N!-#`5*X$myA*UlniPtF@u^qAGm%{gZ0~f<4yEMxVBdYLp6o_ z=gUo~}Z}Hj+HNt8kN^Dg|!~gtwdS@cvCd`RSudAXj)7 z$;KHAefAtU>6-u)sQ`obOyDPtN=N6-s-l^XG{AIW9N)HFhc#Fk)8Y4%F=d`1D_Sng zUp)T-HMUzq_`q;JSW}Vu7V#k09}O$UmWevohV%W~qA>WT2Da7q;NZj*yfKc$lwvtN zVPhj2d#Vm}C8gPry-MuB&pbF)v4u>(s?iG5y>va|uP7(goYQR{C$O-J!QxebsN%;( z-hmH<3#zKPTmJ%TCCSi`kk9;qE4DPEVJJS@r$!eti_s&=9aFytL3CFh96Kt5e5J@n)*{P0j@|Kj|9CMHh&_UL7G`G-9v#^-3jia(ILIU5viiJ!+x& z`s*Fy@DD4+p(!uLCJB+u@29D_smoPN1K)~!JB`H;BL<33e4Zut*FMKgoElkYVwzZU zn7eq#iFENywdX8bqLl6U8AZJgiS%uG9$WddoHUav>E!nk)>hcWV#_M&xR_^BKbzPG zXJWVawouKHD{RX93R>%5#lF0_&6IC9va7boS(w=gTIXHP-t0=H2e(YbcwdbNA&2rD?J`{3%6Y-u)R;iCV;s9vX!`cuA}&e z(-iRzVJ&%TU@qReNV~4~XAIyNUi@R-}jHli9+OxpjGRE3olf1FL=FAs%%30yaLgu7kMeG_^#M$*Im` z&A&=X9G=LVd=q+kZ-&9&*Gmz`RKQ!;TBzz0=FC5xXjw9`yp%$SdvTSu-uuG_1^t2B z-h$iNcoKarpG#5m1g?)$9(ym#*pc&AOzBMvxvQ(z&Bc#YJaPk7H1&az|4ZmpH)b+S zkM5t2ppw&lG;8`~xZPw8Pvo?rqQZ{icNLJL-hZr2{Dvjms$sM8I_RN=J9+mEs2lS` znO-dVh$C}ngYL;>T7Gyn?$^4@v}X39MKoj62OMG}!x6W8Nr_FL9%9L4#kPB2V{UKq z$!OX)HU%H@CY`mYrsPD{cMnjG$z8l~@EY6scOW~bCtbIvCzkqCo`TcVY3$>EE4pFW zPHw&(7+ES}c_xn7KKK~Bl(&qH+dGy$&Uc~+qJn2>UgG99maaB8rXv2 z_wjDu9&~rzO$%--P_DT@H4Za_uZREgXZ<5_i~dKf-jIeNQ73tgU0y7)K84w84(1E* zKSv{WAGnb!tR-(48#*`-jC@`|z}H(aySkPsIx4e;W16^;a+OT;+5qvsxKbvepA4t( ze}nmB@+m{S2bP60CUJZU`=CCCzD6eT$A-_LH&G>`-^K61*SlE?cf_MllG*WAF}CpjtBjh zoJfg-K2r0XBlICLh&NE5 zef*)tmTcBxd(w>aA$GEbKOb6;+x?MrWuj^SnpJc%@i%AZ>j$of<-}`ry;(-S5q%u- zoo|15oQ{vQqRxXGFyu)(tT-5SoIxEx*>Of{Qr3{$;R)Tp;ROMRQ<}G8|y}Tqtxn*hQv@vO$^%MUbr~N z7tXADgbIZ*?B>B}*f(k|bgt8dqn76!ymX6bwd-&E{#5Rq+!BdG|7OzKB?UCuZVHqHcHo$IfAM+8av|sQ1DlF((@D2=aIZuH zm~OY=M5$p@w-hiB|6Yt(KbnnKQ^c008kDvj%OYpwLgGE)GfSSvyNL>@+gOPm>gp7p zUr(1$Y$gAv;mph;4m8(faa&$y(Ct&(N#&&z-LVd(xbcP5`RW0$eL@SpX2#HW!(>|H z{26XVJOZmb4EviWv5{;an_Ostk=3^`q%eSHSv1qz)bo7If+r59rD`?)T}n*hhZ6HX z>%nO03BK6A4R6IApnDpp#s-0 zcc6fRm$+wDCfVgrrxdsC3l9?ILW2v=*&^9&WbVgid{)F$%#{Tv1qYm?1>cSyRa zOV0D9Xv_WckbaUE`^B zW*RND_eM*T8k%!&J~bX(!K@!RFeM?=)pTS(fd-@p~sM-t;=F= zPhow(H-KuVG_&||6w3N`qWK6BmdsD%hE(sv8#P@N%5AC}VQ@|yI^S6Ax1?U2U+*m5 z;q4#}KJ$o8aXAaW%|^4Cw~*HwTggd;tH8X|zO;C+Fy}kof_wHUuq}Rjq1vSxc5PLG zo1P=_Ol~W#^o*cC30ADjO|ACw92YVd{cx9$z**rt&55UB>-N^Ft{BK2e>|7HEVX57TqSvV zCs0X#E_%x?Ac=)GC_SSaT+aBhkV#t^UR;GI9v9K^N9uJ)4>+)V>n&(A-I_uCE;>@O zn1z42ji2>L(x8Cjd{5R|*k_c*c|Tc)_iF^dmRR5s@Axb#^dH4M%U59A16_1pA5S(` z1NrRt%@{&o@R#;$bm-0%_S-|LuD6xbt5%{+u^x%LZiqzcV`+HNU!2e1hJ`I+7Cdnr zGrTHOds%T7?lyGcA`fY>uK#v(w}YZ+^RY4-o*qcI^lV|s%Nf`s^efu}A}F>-nfwel z<4f5_T(&U}XBqeN3q}ddjH79|T}O_Yg&5*8>DiEbQjYGgt>(t=b%6P=gV>x|55Rw^ zCprxO4}O_sa5f$Rta1H)PVd@pcvUin-7->OU!`*(8Y)SY`~q|KovzK9um?v5WeFKq zJ63Tmn?LWUMdMb(OwR_r=_xx zC2FjBq;Pi-AC3c5yqM(mfmCiSaE%X|(1x*T#9M1|M+PR)>gFvhZst9Hcj0DQvLlxB zki7^Kv>ahPnX}UoS){nnP~c(jXS-K4z&o>f%=B?n?bTbxlxiXLns-;=w#Z{_&6@_4 z_En&g8-tn4gFle}HxHJ?D4^_vKJLwC1xOxkN_yur_`HSBaH#uiw)lQI#u}aFu9Qns zala$q>$Q%oR?H!_1HaHOYYyq1aij6C_tVi4B9{B8kpEO}M}E4hG$6W=&K@7a7f#Q` z#Aq8Deqw%wf|P{R|DL1au?nb>v0~P} z_N-U=0IbqdXVXenz=Tn&(Ans5t+CW?&T^H&(tI$DUPd>fVz&qHUg^PwZ#ARmla-ii z@?=^Xor>dzs?)eeU<&G6S!&ix(Ea@aj^)a-nDyc4HQ*y0tImfkoAF@VnL@`p-r~#C zhw137P8i|T0{-tOvaWNo>@w{EgWyKYnfek{du8|yBgV4@cjvLNt2enHYQNAEf8)X- zpK#S6cQoiU$BX9_*tS+Pwj^LG9@zC7>#xqEwPQ?3G;JQd>2QV%2N!Vazp@~qUGM~N zJqM$`XYpEk!8K`lo{-q;!x~F%m}av%@d9DX6KZqzq?JVRK?B-0C z`QfI3vvf16ih37S!3HJ4ebt`FC(Ip6ea1%YhItkL)$0fE<*CjJ7fd8Q#lft5_(fj% z%Ua5Q5yl>L=|M_qTG#v*nvO1FVf*V)ru__cy!54sr`ymz^B`p8 z+tXqVcl>8Nh*aBKxs)51`8fh3Pk(e9ewif4(sosWCpU%t+Mxn&*G5z1g4s0jd4%Zg z^nvtncq#W`LJhPRm7?mKQLJ+GN>+U8F)x{9#{6bq;iE-DerS;bC5orv9l_J8da@TT zB-IFKMq76Lt`!CCZmoIqi_u?)6n^2`q3q+V@hB6JLcU^GdVHh~KQFMN#}a>FYV<;K zI6MZ+Jtl!$+*-Ii>>}6Z84K%53&HZ=8QyBIDzi`4VCFMNbJEHN^kM^}HuoQRKV}hc z@K**0__*S{ss_|MP+OZB@c~ksZQ;m`U!s+InQXe^IF_WR%+J(XK=TwBO_S#_{_AZl z{un7xw|#}o$|X3;giN=)4HkSH%OpSNKy%SMbf0k?>RwF%dFR<+H@O32Z>PcZFZOJi zVlwZqYKc?hteAdR7+1JV9}mPxk#|Txh{|MW*!uyL-R(_RM0aB$7p3%7>Oq&a<#G(GSNn2(HtNjiaGaL^cfRMbe;sh)p4wSwOM?1pCk1Y5+E z;KQB;@KrcN&JSNk^~VzVnis0IT^`e@u44<9Uyvnt919uQT@bZBik&GLMhfmh->xu@^f3Fm1Wr2`c4AEewp}Uy#&b=_y@+stwaAecMU!Z>CB(^czM=x@N72@lVwqB0)CzgUsqcxj1=NGsCP9J*hG^g!u3AA*vh_+RR!Hez& zSg>A6d}fY>_~ncSJfHRlGqWBd6fR=Zmo8?ll{FxDO^`$^t>WFoJjk>vp6X(jll38a z%Ad9svfF1tZFM!gf4>?XWe2e-BVKaX+pch9M}NTPY#VsvQ^kyY5AbUu{$RnzmlzcK zk<oiHS+K_G098O7%FVXyxETlgzWF2>VxYFX~ z6l5#RFiL&68*itxwXF>_(o}+MMr70s8{)+3W&U7B)GRt)t&RpGBGF1X(_7DTWJk+7 zS-)2tbv^gNB%u@L@2^X{!xG@pB6(&Pf10hDzlIGcisI{DTQa}hPPLXzaj>sD4N9)W z)0pGq>A0Ja(=F0v*|#2XrLSMJoSg&2MPbh3N0F(NCRpM$Uj4_1$ywIL>#t@zl;86& zUuo9vvt5c_Q=MT*y#@?6KMv-Hr?BO#7P1k$wQ5^?eQOK1WT3sQ0d7rW?C1{@dcE%v zRwe9bbwP*O>J}j*ZLA2L?{mS+O>iwJ#ly0%!vEIdDUAEgb27uapy;YU^YwRxiKbWK z#l$53J|Z_a{N)AtLjzgj3Ta9UQfCGGPV@Z|lUVTZ54`lwc+o)T>yTes0g{uaGueY7 z=v4ZSi#>k=?b%7rI+ih84;6OmRwO$<-GGcO4U6tip^>UhH*p2S%Gqv(a&HxNWN|pljp+9HDgw zKg`L1Sue66SVfk~EDb~%O7RrjR7abfn$UChU*1sQ-iOLrvVU2>VNv^SaM99ZM*}X4 zY>V{qLGgX)DR>K0BTBfCp|iNnbJx+|S^fO}x*D$d#6C*BbeLk}=b^7-5nkvYLV+&8 zIs!K1*N-!xzWFNt3AzcNSDyr@eLOfn)FNGl8aQPjuwz=a;MunMbajRgxUXr!{xf@F zLE=ZS3drUztu%(`ON!b4I!&^*@_-@xI$_q00c_XkZ}@OM;S)>2Lo54{Q!l;(<21rS zS#%AK7kxmL5u0eyx^T8;ih5mzQZMaq8%V+xAG}Jb2EE1k)NsL#l?`@8QHC#;3bruI z&e?2xstsFLV#2KR2hh5u-dMRhiXOiZ)7XQ}^yX0pE&BYJPU;?@uhNTH&+f+B@!iU} zyFqZ3j?9GSMZ@T8;UAbj_>ibzraU|mjb-x(b-?NW+Ts4ZMbzH*2{OJl;N8t%Fu^~L zUDUeCw2hCmUbg@i_^2N*Z7-xpR>fd!uggAeRAWT~$0Ox(KC{(Gu37kD4Me?=<+c{M z(JSw0dai0nw>|9ni`6n@cIz_uUYaEAC6|+EMH9?*ktV&kiA;8m-~{?Q)S*+R2bJVX zvG(b3t~hE3sR-+G*Y?@ORQIEP9@ez`P|+9ky{7f)iU%YylyClSo^_gsG4vR!09 zXaPB<8PZpq#dPWBUD0>r2H5D`%02SYBBp1>m2{2g%U$z8zb!%FJrt3a&su!uHx%MW zy~5CIWANKW;XZT61)Tn}oHJ9N$*iFoI$b=77I~H6q;Xv|_3l9O z7-IBty%blgmq&z5ta z?i9h>;(CXuA(8mNKvKwt+Jj<$GjQ|X@_f(-TrhJcTYoo_iQfCL_$xxs-zOY93)4Yl za0geqcB9Gjdf1U}&AyCUEcmg9fQD2DT+$iCPEDA|Flz?A9QP6SdmVy2|8#z{{{y%& zN9gOBf553)Co%8Qdc5?bruK@ED{`CN&U=R0G0#&mbjLWH)bxL1qNZ?fXt|f$Qa%Ir zCn_Fn{^&e$AIRkNOAtV6J*U4ylI#j=?-7leD645~O~#cP`12333Ece$2CcUx51 zoDsfktJxZ`xwMl#6uEosBFJwa@HwLQ)>NMfzZ1JPL1n7zf}0|=U4Lf zYyC%!+H2As>0>5f7n}A+Cv}7GfJc(tV`Z=(F)p;&V zU6R_$pxM8=`Dq5_I*+ED_$YE*Wk@RDwAo^n zZ7kOE4UAt}kCzlP$=GoI=H`g+^O+uB!B0W z9dv~5g^8^ubhAu^XC@DVz}Iq=726IDXTAB~50CP1wk%;4t&>^6x>C68)>Auo)inA( z{WCt1mtz}4#dNoABUQXT0WU>f%)(!iZI0Ls&{6xtfx|4m4A34jJE(FgMJaT_|{ieH&lb(W_qdvg-+u1N!xVKxft^}v5meb?2 zXV_gC!C$v)GCEYaa-WAMVs7ab${At7pZTHAhG{q8^R!5KrYOnonH^&GAL7}rk*3Us zy76|XHpBn`_i7NEJa#a9H@O~7I2(Sl`x}Rr&;(LkUr+Lu-+3*GE?Dku##Wq?;9fLn zGm-KikeVIIf-Lv3M_*J}k?&{Lu;whAVCacU6?W74*hYG0xRqI-(4cEvIJT}E0B>CF z*vh#YOnZa_MNi5FW8FhI`QA}s#ygF;SyD89brP(&QOw>=UCN4%Hq-|1`GggIKl%95 z)y(YUcvxVY#_x0<&pO*jF{#sg*t(#x%zcH?dW+dMlM&1W{D!u1JIvBQG0 zqn+6jnN`fWSe^5EJ{EI+>QdU3AvABw2r|xIimmsC3indqu=)4_fisrHE?$vk%Rha> zUk{_1%8n^g!uxdvw zoBB+i#jc6q>^uFL)vP`6Iz^eFt%*OdbO>9zdk);n^;Z{GCKC7+NbZ4lL3;vEF_? z1pf=d^P*iewmOol_IoG#+2RPg?=M0}L<5RSRA~FleqNN53!9!^M%R@pxZYV^*e`lf z>%>JUlfIADhkEc2)paM#XV#z3>@F{g(#A-(|r2>@9GrO@$WEE24s{%W2B|T|_VU zaY`NIxYagic@@I}Sd#G_qEh!#%Xv-8i&;mK4ma_LVma?&=||Z!&f~H3S23i*k~#dG z1li5cP|kE4xm?zziZCH(yg!Cra+=Gm&qUI<#bUCt%%u-YmeY38dwjxwhnMZvY~U*? zJT};e>B&vSXrDH2{Ly^=xaBh#ary{nx}p<|L)Ni~AzRsNGcPdSF^EO$NVCwr5pXZM z0~Z`QL7Vl`*^rgPX;Du&+nc;^{DI zc{F2oFjSr|#r46AvfiJdpHK54XlXP5-bEXZ2=`+r-=Bm}6Cz;4j#=zP_XMgN`vRo@ z81g-5!eM;mLFT5S&-VO0&9!#ShMDIlvg{LHWbW}042J)~XZ2UGR$o^Lv8`m)HMfuj z&EWm{O8$erCb}Bu@e<=t!>SdeJ)I?=5{IoAS@%;ae*qziBjkZJmy*e)mB7xcQj(`4dVe*HXLh2=bXM z?7we4$E%ACnDn%pFzfAl8fZ2J-)QQRmDY7uV%Ezq^2_Dc)#tP3?cVIwcj>y&4Qy&y9SbUcjxm%2Qq}qGT!E{<$@K%ATfC7{;%sS`)FBvq(viiTse*IjGOle# z9i$3(cH`X*sW;Px_t!CJZ8eu@Ti;@qu{Z)=2bvQvbW98PoAA43(x6@0nDvJ^vdIMj z*q8kd6$U#}&#nyw6GBLJXClUARnfj%N#HPh1zZh$>G0y@F+62zfWF3o%*!yp)^&Hg zkm+rMoGW{oyMGiuou9*UMl4`Kg^t{qvD;uEfoBsIK7$tP6oB5Ay)b6JJZKO43+l$mdr*UVv(&F2LTb z82%7n0qK)Y)wR!176+|06W{O|C*D3dv@SKPi^Z=0hca*fV=9}D)E&4TUN=K}zxdkr zZ1y<9u}&}3OWd)&f<&DvY+{%V#;^X$CpVtq^SIxb99_ZxJS7R%dZ*wi8_WL+tKqel zM+%w8TB?jM=RXVcy{esq`A}-H2kprq>-?FoJa-Y3>mPyF^)!gNrov2lDZHjNSyV1` z?&`~ZsVZa}EOJ+cQ7=vKfW0qRsLF98BP3wZxGsoD*$JCZ{^Cu~|KQKQA>4a$GG+HJ z=LBv6s}VR}Y4>ly`0LAR2i!JfM#*XvpUPoKkO50~lw_{YFQVii=~|T#9hiK|o*paq z3SG)~ob;V@sGU;`UdD4lci0a88;VKKaVE^y`Uc0nM}gzj2!5c?4d~RfWszI!AUk_C zEesLzzB`Aq=k5DQQm2W(F0YQVQPXLVT`c$HkZ@mh%n$skHsEEM9)ZQw1}oi%v8c8Q ze9X~L+&jI&xc|TaR@d_mGC!Tb3y-p)$gLNgG%7*2U5@Mg7>Zw9S5W5R7O-B!!POZ< z*x!+5=(*z`H}be6#w>EA#|!jG>i|%R)(Nn*l4VJgZo{-|-P~V?D0CUMk}XoUgvXM0 zbk<3gWod7P+Q16_OZ-%Nuv3ODU49v+d=h-JFO~TT3DSI-sSJ!gr_D?Z#$l|*X8yhX zQ?RVB2l?nLn8Gi?o6kq^IXRVBrSK0u&Tof#q6eJ*yBMynHVIrt{e_14_u*+B4~q(9 z;G^11HvYCe``B}jZ@J_QXZ~9&us>5A%8mx(!fq8>Fja)o`IW-F@(j#fx)Yx$O0!M! z>)7i*Ibg8Dl|~P`flUdo@aLdF_Mm+wEiiZsMoZ#Z`bBB5_Y3(^&BrrW#4V0TX*MmAKi61TDZpUzzFq1IhoKle9hFSuwm zJe=U}foL#awu|?y)8s@IKXE{>DW}@`gP(CfpGit@VIHPB^ey{6cev#ns!WI_eoYh3 zxTp_4riH9k=xeA(MYHP*leom|Cah9g0e)EfLf4T4l-hHOYpz!mdTTMbnUkdV-!9_2 z-=E;WUORr&YstD4rBQrz@qPfsO|ZJKm&v@kD9nKW#Z)S)fQd(S>k- z`9ikqS`@@R{*D#QlJ8S6r8QEvB1ebacx#E^Oz`gF*L+i_j5=&h`^UQQK|ZdOF3hL$ugI`uzjFSzM>eIuH(|!V zo4|jzB#TR(PnTA-;mKM05U_aU|IcGP27QCJp8;@gXDZGezJg1e)yHd&OMt^^f_ETn zCv9%rgi$AyC~(3B9QtxHn`M&$jfHzeX9j%c;-ZCpvHUg?WeDp@r5fCQ;DmDrDDyM? zx1o1y6rUXSkpF&f6SGZz4aX!b@YIp3aP{B?m^0}<`q`x7=uZ`xU%sSv@{F_iU&=0= zlpu!}Pp^kjQt{9{q?p%9yT@A$m<>z2ySOw93A*<{mlkT;v#y*u@YT(Z^LLS8YmOZe zdX-Vske5teQcWoJZZMzul0(a5-=IqVJ`R`?f{ja;!H7X0;KB`GhkG?s*-b4?wEXWK z-oIf-y{TbnGAxoEjrv%-^P12Dx!jBYv?REWfNWHJ#iMBKI^?DwNBf{0lIqan&L18N z^PlR|$XD06R-waQk(P{2Z9n)~BU@mt&_#ci_7`?(8?X~UwOPnmKYs9?fBe_r?Qm26 z7rgyA4z~T4V^7wv6=u!RxX^F`)^uu;^pmLCr&@B@TbB>BI})&FXb`V!WC&vi4dnmL zX@zZ$g^&@@#tA1k*6A~sH}$Up9mg$@I>C#N7TiPk6xYzA&TCwz`!Rmpg#xTS6@%BK ztl5dHHmvYp#QO**{_aG8rq6 zjeyctJDhBz2sIkov|*PLwy!%tX9ouJJG;8LgU<#qX_XFMcG^7lpU^LP+;!Ap>c!=v zZpG1j$Nl}dVb#3aeU@c>!Lv!#8aS=vr<)}Z9_VxuxsA(_>vlukcEQanA-B|aI!`|JG@O5Z7zBT)U z5B1XcJ8SDX7Q^WKw?r!6l|&W-FQEm?A;@JvlRH_*ZJT7z#^~6?^4Mp!8NpWU$IyDt zE?_AQQfuH>IHyql)(6PH8p<^lU+3Zb!BO~QJ)A|{k$~jfNmQxu5S>35(89n|T%E)!_)?U}zPI8*z5kq-HF4rRHYEtt#6K`py&XDi)%hf*>;b7cSl`iF>{m;3^#vIHz0(X#$vJ=9>Xu$S0uW+@E?A?n#yho?n8CY z!x&T(k6q&h&r(J=Y;-;Z;y7>EWSx&yA{XWoa$MLa2NQftfsq^aK=sN9YVeI`t`4fG z^W-=c`$d9ImIL49W6Leu^Bi~CMREDdY*=oa9mPhPaC#QMxv+!nSQj7X;HQ2K-?kOm+2ml|jU}*CCr<&npxTW$&e%VAI)owYx{v zaxYyT)t;JY!jgSWS;bL9mUK80hn?RKzgKkd{Wf-j$&MTml@1wCSNwWvOjN8Hud?mc!%yV(BRuIWXyYIS=Uz<&r4T#V^7kyHUmipFR>A&>kppy(A7+0kK^Zp8D&U6d zJb{Pd=kP4QkS&Q5IQ#bc+;?efI=0vi-w#vZxc(%xo^cyzekj2#sdnu6`v}b3ygbne+WnsfFhekp2zIZ>A&)^`I~2{V@sS=(XQt{Rcu2;p}&GsmjimvP;N zfrJO2fc?u3bhmj08_%Zl!6(Du#@K({)*lKi#YKZQnwqgtiS5w(V>tG2ea_u&8%o7> zm!a>Kh;b3yAj#i@w(ng)R+1HHJ!(FbMJclKfz{j>X(e`JeGRTlnMR)lHo(K70t@M0 z5~kyA{(JXitk@LB`Kgpihv;-kn)XV` zVNdHaa(!ru(JphD@svvtHBf>n`^Ml||9tFEtp&edru;fD6$(46Lg7Vq@LO^^J~^KP z11bujXG|piKJ*%Py??~dl)MawJ_%>Ijj1s0@)o{tz;`eB39ATFLGM7+`shf2?z@R(Xy;`{}Qbju-misrVXN){n)zt~DqZ zGm#ET6yVR;5ZpD`p49bI$>%{FW-lDaan5O6d%+C;X3BYV8NUU$ZgGLQ`G>$;$s0y4 zIR};BzrfRv_waGA1-z(W@G1Nn$_b3;oLeFMinldTS{_CNXB0tpPBct-aT3n>?B`cB z#Bj~>^PyqnMV>ub2x&u-aK$j;%;prqKa3uX|1v^hPUIb4n`OdfneEiPO2@%`a067C z1i`-6ezaJ67Id4JLq;@mPX^_|4cqx#-p1nEwYoA~vX?JN$DY8S!QEge|&%~JEdlTaw`VX(P7>E^=&6%hT$Bx()tKm z)im*`p*KLqGL-)|Rh2(Ip@6gbWg>WFb9kA1#%P%=#ms%G@Q>i$nd4OmpM-h$;hOCt zRi+7^h2tsxkS)pjXX8Ild!|2bHO*JGr>p}f;G2#%+wpZ3x1)3#mVe&O_#Y!lV}o#Z zxUdByHfQ3qFbVpXeF&C4wWG37!GY}V$roie!1SyeoW>6|b~v{jvd8G~2fU1lPu>jI zHkgoqU=p5t5&+@NCOE$DEVb1S6{1p=u&a7M7T?@Ob{8I?=kNPmQ>O!IgwI5k#|1Fw z<_wnjyBg+>IzXotrvrWSp^lwXX#KrAu$gwj9fLdyyO_+^UVR4V-v;8t79nfqZ-|#a z%i>1q!`RrNM5Qz8xl_!TI=&s`zof2aPo0ghs7Q{IB*rrLf7jsR@cpnQzks)Vokeo` z$^5;yQcO=z$WUa2^J{jk2B{mgljUea{&f66pq}*!`+Op=<#wBqDA4AFhsWzLr>^QExSPvI{RoILd#!TzF z0dbOsjQi`%4BnSRj)WIW8$OgRukGeD;!eO?^(h_kTPwSn{XO9TGJ5EdrV!cR1b`w%sNRZZN_a^!@y ze)$9Zk*tL7N<(PQ$^_`SXARGFrZ9JHO-!5d3{QO!7=2Hlz{HL-FnP}j%$d1}pY}B$ z?;CHRHSX2?%nBk%yD{Pe-Pb5ukkYt%mld4EnYrh5f1Y^ zgoWHzk@BGpY@3wC<-U|O*fy?YB`R(29++L_3ExTv9G zmo0pEmt-y%-eT+i4Ge6q!U>Nfh;znZ@puhXyK)G2D_6sbhHo{2LpPGf+++AKS_c2n zI=<)eSW5U9O^1h+viNODAi3}|tQ+nM!M8jh!Rr`KI;TJe1`cp{SqvP`O5#Jy;`#XC z)3xzV*HGiaYCg^-h;nn1MIEw(K;HW~?r>Z#`gL^@8*Ekz*XmNK^vVx@OK~i(6mN^q z^uF*jHmSjltGD>5TMyxmsH=8ccnR!HI|1Gs1NqEOFLGI{hPB(?z@@}0kgeJYhs|`s z!PSrdmMaVTBfp|43;_El_1r9@odPfR4xcpM8N9V0fOl{dXB4XfOaC^b^mI*9>k6dk zMl0Hw$AS8gK2*y;gKmfRab`V(>Ch}+l$doA9^X=6dt1k`o$;}#cS;A#>dxZ4Ws~^O zENNaM+K}u2YRP;T>ae_R+HiT2I*olG@ZzMNL!_lE_{Vz*jvr4}mH8SHWhRr-w}<#- z@exS)`xO=&$?#^uHTdqG2}#^gq0O}eL2mMYkh6Fi`*G9)pWEhgPO+yQPM$SG&9XbV z;^hIX7eC;q+?$Lp<+-?cbPPUt8i3kIyG6OtDiFATEUdgJ&n9FFga4bxY{NJecJ;au z`+U0u!tRV@3tjdJxh8$;aS_!5n0O6prOQOq^3wTD8JQRW-FWQa9PF;o0ns3J+B`5EPk!#=do6c> zddzFS`utD9YtWBXxnEHA|2W4-+b2$GbD&W_P1(x0_b_?W8i!v~)L`5Zd4U_@235=7 zbIun}U{Kpg7B<%pJSXpjq7)m*n>CXEAj}UU72fhYBMe|}gbZ9%dCparZLXbDJeTkJ z^$aEED$#>7Nx?m!k1J+R;~v(!!^QiReEIXekZ>;tcf@_*bpJL%>D?}9sZoS;l`Xi@ z^eFCc*hhc8cks{Lzk<=y=?vUo;Kyy(xw11;Nqwg&|Fqz)=-9E*+@Xcv;l6eauldLi zl=ZyPZ>PZWGq)s#HDmd@axLthbQwGLck_DEDEllZXVQLydsQf{L;of2&!Ka0{;wl@_$v)Jzm1_* zvsiRZjbjs6&tdMnq|l*RoqTu`IxyN08aIisZ{r?3COrv1rV4DFXCZXuz!N-sRFQ>_ z(8iDjFKXkK#@7a)K1rXxY^0iL-7saZBNprb2gm=;<?l;Vr!QS7t-eYloz76U^jure~H z4MPs{hL(fi$B4IhW9d+6pF5Zx+oVM&-%By}Y9MPnlnP_cc%z!Y<+{J%E3R$52e~#{ zOiOJfUl$X_9$9ISoz`k7n<)u5PE`y0wT~!&whnV9Jq2CSO8V+%Ph&6dMd>TuxX$!E zDk#q9^}qjz`_q@=phc=Ie1r_ zwJeTbV<&hO4F>U37p%m#-MPHUG$GS{X*auic^B)-Dr1JT<(YSg1@WC11ZG(@wEsN_ zE14A8-)P2y@tX8rF`ph-WpatuBbmt87aW|Qqu08rFy@Usj@jG`-s)3pjgNDjNN_B< zCkrdu>1$ZvR)pk|k4Lu;!$0u) z;L)PTGX;hkKH;K_^H_JN5p7XRr%1`GaPFrg9$eqX^Jl9CN8x&WDF000b`7B+P37>u z_kR?fhhL808^;@>Qb|IKRA!3?jptlPDp|=0eUX(=gtA8{O&TigR9Yfb5~=50M~V^^ z5-Jh0ibR?X`tuEoGucYd!=0PAcH2dCAE{OXDUqR6IJwtA8Z{rP4*&)d`maWh@G-=dAUu~vq& zBby+1%ps^$7xGqqJSe@>Ck>82Y;X_{w%+&qA)D>g%g$@$^7Y-9#^{VAgM6Hklg*3ZL-L#pwHn(ij6v&Q;RTLFyQssStg#`FF_XTD557VIaV#GTpq ztbaO*QSwH(sN|vw?X~(1hO8X>rG5CBM;bW%fpAvV4B*2m2XozaRc7{f6_WZ%l>BOA zcLF5v;3rEu-f}2!e6ya14jJ^zFHjsMVPTpYHmpTL>-BG7)i4e#-_#+12xd1J?Ie4@0PrW{RyZ<_{C$47p2 z(!7`S%k4T`rZ42vD427nlV{-ec0Us97LP5%>(Hb$4GyZL99XTi078F(qwnht!l z6IwGz;hBn`7^mSzl(l#B;hR^H(JJ$C+a_6YX;%>J5pun)eN!-K>lGGjeH!-mhl|EZ zbiqIK$#C>;H5sTmmJB{Pnz$zh<0#W@U=ouiJ~46)boRExr2BnLg*x#M=L~q}tXyo) zm7$+4>mgY97SoK>@oHHCb2H9{*CUGX)LcEj_LDT^w$6nQe!A>zuK~Yg>JCflDxmJ* zZsN1W7DY;}5K~(qIx%J%6)imlE8V7xEJampu2Tl$K_}F&eo+UEcd>YM<4W@_W zR-m2LBr<8?C(#rq6K?t01`6!!!86vCEv#7%yKmNjOyqm^W~c<6Io=SPhi|62180zZ z#z7)a)&h3EudKD_o6rr}LXYBENB+%w1;3s(vG|u)Fg9@-J!gLylkR(?z4CmNar+B* zzAC_{cG=s{d0y-ZKp>%u;=wu$E4%*>xbjexreX#`-ZT7^W6IC(6 zcnbHlJ%>k@wBf`WbH4R@2~#`umCVQ~VD~0;K!j2*90-!4R#NJyrzC^TZKib4<$tg| z_Y~>ORKY`P3g|ufBb-dF#LEXw>AZCzEYPNwEtMC%#gEUhgG+s|pymv&@k+*jkt#Iw zNj}VW%7o(sUjZ}SK%EVPFm*yKsPxXk>*6BfH{>*bT$9Om+pF@byj;|({*N|G55$?h z!T9^(>uW<&PQt#9U*P`K0qX2^@I=M{n$T&?zio@ggwj{|Pqh*Et2~Evl?{CT27S=J z6iEAhZj(Q!XYe!ERH>DfJz(Z;O1sx`zqV8=p4&lwFHhl4H}8>@tPWiI zC6<xf||v4y66&Jp$L!iv>j8 zWB*&a&9lf%fIL2Wup_Y%5(rfG_z z`hFDOTu=j%n)|UTz6pBrO1V^rJl9W^!ufvJSzJImZ`BQjQSDoJ*oHOuc)CCJB13?3w!+-YSgcjptADUSSXLU&~~$te!%rxl$+( z^5Z_8`tM{ko_G=tYc&S0H%nf+= zA9Xrol`4Pp^smUpEl+$(SKv5C2eR)5tN5Fb`M`n{=f@Ud85B@?T^KWD{K!t5o>6nboTi1N43#E_-$#Wqs_zkW!7 z>4!cux$*MwA=(J`*PR57ln}@={(-mSwYYMh2A0RvVB+bceA3G>{@-#rzI+DdJuv0^ zF7qRJwJU@w!4xWVo6`?FJ5hxOKk9e%J-irxdV0-;ozsn72VU3xR0}x`E2qb8y@(Hf zSmFM^0&BBWj!#y*1??+SV97OQm~l~t-S4#k8G*mD_F)q!Io}nnxK_tb4A!7q%8qia z))w5i%Z88Xh@b;Zmt)PtOc+-&4!#bk#PJHJQ0LjiGE4Ibblw^hy0U8pbnAY>F)u1e z_P7T^&H7M0`u7w*(O4Tpyn=bV#S#7^aRk19Jb*rRa6)@6HL>I032a5mHS%j|0|uV+ z!|m?}@V7D6qAhGKnd$jQZ09xxo(Sxip+Cy_^!P|{{4C_Yp&!ESks#@D25X!2sQBX( z7I!{J)IZ4t^m?^mWnDeomw88a4h_ctl3#$oTsC%&TMM@@Z3C)iK<#u(ak$`9xH%*R z->*o7DUWyYz0UDCeCQMqFDS>Xq#V@Vm<`j)8}Qbq#r)c;q5P)faiPx{&PR726E9Kj z6#G?!P}l!p?~iHmavdX{F5Sk?R1&T{S%KbHEWwVTM@!MpfJ^+IN(VuOQA!br`nGT65=3-|l)r1?ae z_8d~Ckpk~nS6v+%4_+6IA0CZgngijGPY&JUChT}q&#`%b>mg#7Hc#VMa8S1eb2BsN zhQnv^5hQ>PtRDm;Bu)!s;DJ0eFAG}h(&1LhX09*(B+497A`V>iUF>XkoR$AA#fk3n zOfPi}MyebT9JQ{n+FuziOc>4|o4ND8U9EWRZxI*p1@Lx?23=rtoe!$+#+#q-!us1P zT%Jck_uLMwNxuQdo<4))UMKi6`!4o#*-}Ag2Kd@3nYGkf@o<+|7`i7L-$k55i`OIJ zRP1y9<{86;iMBYGG8BG=7p@K@9_bzUYKb!U73K*G#@@%)v$X;n^$O;%mg5r+>%oJF zT6`=PJiP8PFk{|RP#ar}!FTsVW!p7S)fV#QHkZKU&Y$Q!u>^OeEI{vV&)FG6S*{jZ zh0i?wL@sj#J;xa{t~J(JTvkAZB;hf_bfZb-RKe+A+NBTIyh zyAj{J*IC$)*mLQP@4+Me6q^5DN`E#kg&%I2l!RUrKWZl6;cm=NHEraTZ~U3wU3VJR z5KK0`(xWc23DkO$9i8tvooUvI!RoIj-!R1$7Hqx?0~Q~JF$qU;?}+zgr0N#>5U{N=+TY+s@R znG>hYNBbz#kgf7C>c=khf270ru5xEjx5_e`&5BSOJPKUOm0{S24Cw2SCQFaGf^v5P zl$fT1R96HJ{P2}Dteh$E(xl*v`NOi$hP@CQ-HA%+m)QgNZQ{k#v}kSQ24*t8LEJs| z0$XeS7W`(66K}5;`ee&{Ft+)oD7%lr>+(?G@|JX+O*%|n_X4h{e1>8VYJJG}K3r(9 zWW&EJ(`||+(D-IHF4*><)Sx~V+8Y<5@8v_{uudc4-C7Q(jFYh>YbTsY%LiBG)u8=o z6`oo;il-T;L74Fj(4DiCyi(aqtc7~hM{YDxy1fHlA0SZn>N$K#Qh<4t736QGBs>%N zcK6@RhJ)@xy}W!9tW|Y{hciBt$W4>@5&e&XKGB|$iqjArV#_U`wZlW3-|VCBO^gh$ z#g=LtvTN5Xa4DP)jiVC5z%+}MZW#;4+f&iWp#;RV(=@k^B^ zR=+P{m(NB*&a_uBwjJSZZ4nDwr4GXb0P8NxVrji@*%Q>GqZ5&|oGqD673 z$+r(=_K;$6=wmgMH@S)%HOe98vY?rbya8*)@*uBR2y(mR_@{O`{;O*l#LayMIX@)m zt*=R<$|XVM$1h`=Q&R$i8BjIofXnv8GbhO)1h%3$KJ25{M&0ZR23@###V_PSJqQM2RuF*6<5qc({q zxXJQ%_1j=qEZi>>c7WC37IJRy8vcD5g){vTI6e9exE)SqFfLYXFX+=vt{oN|EURX> zx7eZd#sc9#SU_fH8K{*Pz!kZHbZ_WOn7%*?e_kJmHV&I0H zt{%yUuiOk%9FAj<)=K!TaRW42Bpk~y;Q33m_{0bY_Nf0Y4!K$awUNtE`j$G5zETZ! zvg+J$x&&!H{90TP*o0!O;q<3nE_vk{%P+2!Mz6y%bkC7sSU1>`hkXj>YKm6u;lV?A zuq*?Yx`pw&?j5ZC))HKOdJ&H-7v{{?*5ss_1-*X5jIJeW+}UO$ZK-u32@b*BOXUUe z2o!ks3eQUGchr%R^AqWNNQ6T_z4)%$98?%B!B5p4+^Dhp+jMhrF3X#W`1n-nj(~O?~)$tL1pkz8&4t zhH)L^DxBFM4QslS;r&}P8YdCSa-+29(G$ru0TlVbU1woWO+C<_yVxnr(2qa%} zysQ?;8?5#-R-Q?JPm~sA&4_2=r|iXT^IwQ8M`U4?tH1=yN`pBc8$@*$x)|kti_txv z?ABpj%-MgE3+e~Z-CW9`ZVV4?Plt*6dQkd9753;Rkf3%Ef7^Z?Ke%Y}s*4hI@MR1B zcCHEiN`2_J>NBF5#k%x!N)V#(EGH%JM<#gJBQJ7l3kPH)bJAM<0ydn<$S<%>n&lwbg7-YlYKzQr`wQF^+?lMP_CbqQZB zsm})uegT)NuVCtcPACce!TdMwgy9~l{KuCQ#Gpb6zTFs#xjqeo%efN`kO)*uc_7i0QcC2Cv}MuShv(|E5Dpej1eQ$1n`nU#o2 zNmT5|VsWyLIn?AT;zyZqQRQ}9@OK}>pRYfIGONeXuKP0h#xEQJjaWzDzC?>cJOTj&mTY&G!H-&xS^oZsunc z0=D~=c-z=_B)IA=>Z_abdw(M!!omUNKNr%!^Q54y+8ENmMT_hfx$*t+luvFmrnP-( z!Wj){uJ#FxXHMoZMn!z2X*AE2ZQ^IP=|Q5ah>!A;r4?brXu7bAcN5s?sl8q>dz%dY zd+5W5Z&c(Ka}-gkD@?R9)RI4|8cpZ8SW>Ca`#^e=I-e`H;$?E0+-hNqhuqouZ)b_gMDwNh=}dDOCByDXRv%iM%}F3)aPcVKbBj-o*Jx z{#}HS{8NL@JG6kb|L4W;NFPUr!@2xgYyRnt;J1ca@u?wBtTr~>I$F_%>iG14@ril- zhx7^dX}2n@o?Z^AUyRYAtP74t)nKu-8kKf#2L)l*6DJ))OO&%k)9?(+9-GI%wYw1Q z`>9m(!f$w)PH0TVQ2Kg=DtWs{htIS70!g{*RPOjm>m4^aQQx+R>V1Cqy2`&-3)a z?1jtdjY_AopBKVt>9bOj)Icb?_!N%MHO8T{D`>5CBHvoHokynCW9j8%7|<9<{IyUj=O^Un4&yO_qjB)6H<(5$1M2j7md@5yq!Ar^J%d=RY;uOqw4&yaj$6=)R5SpuPPFocpf@g*TeeBRL z4$e2CpVH@n!_?0h;3ZwEMhw?pUV3AF7xBVGl|Ktn~5TU9UQ zhf_-M=%k}~lS<fJHH#w;b<8m1 z>_yCZlS|IeA4cU4Deyh-97S}J5j9YohK*IaxcQ#}WHiQ-wBt7TBz`hX$`>;89`!?i z5CLZe9Z}M-5SXCi0K=>;h|0F3_;$f~es-h^=H@C>j{y|b5(o1s30ZinPYb%Xn4#+C zDzF#0SIx78?2P-bm{R&!m}?z}172z1!K1$Lw#}J`Bo|@pUVE^6F^kj4F|?%piQpkP z3uB~vpgk=Ivr&=ujrmOcz9J5&+zUaCe=*@l5H5?GBT|^^#MPEuBt0_*^44xIRI&=- zLq5BRKHnTdUGI0pF1=9JdQPbAu``R;Atv z6%O~nu(TXLZU>q%Mvlt8nh8CVhEP$f7X7q81ny5y$2reSSgMsD4DHXypR=W`{Farn z=4)@EOqk(#7M_5OD~3Z*coQsrx&t>!wxh~T4e`4_`KVr3gaWECwOK|&XjWH(qxGB>V_tyjf z>>38ep9b-v0aKxOZX4-->&O?4%*VFDW?*b#24gzy30;$>Vw}} zbCk4GgW$TAv_RO;eC}ywiE~Bp{M<7pt$h?44$kIJC4<5IhX{Xc7WgN+-u_Fy&2$KhjaC}A^XSYhrS(T?~UEZBdZc?ldQ1DjCx&T|pU$Nxqa`4)Tk zL|OYD7HYBmc_?#Fjf9$1qTBm&@kxU>U^vE|M;XlJBQ_1;ON_Enuj?Op&0c}sZi?Lf zqdF*#nmA z1LuD8AMSOaHDBDIW?d!p zBu6vVz)@%@m&c-7t{`N*LKJpu>Iw1GSxttIjnt-7+pL1>>*cMyxF<$S{!)Pdf zJVWrym6oB3YZRu9e1@yHT!e$0|G|N$?c(|^pmT_TFzjy6^+TZg|`KF@M`T7WUGa@vskEUD*l6`GJ?Kr zr5bbaH>5$!m*Tf$BVpb2izIE?I@dsBd z)8cW$Tr7A35%l*Ks<_x{;5I^iz4r8QT$uhi+XJ7?k0h`ur< znIhJLyYbm}pKM=yc zy@YuA&!XEie~W{Qv`TLs97?BY2-&vrN~rBNiFfWX;<3e7aQq>8&aF7{(a9sS32MNm z4&h1fFX5oel91r-$~$Hq!m59+;-VmdebvuUdqNaC#J|PV$*)BTCp3AbLOg6e@e5T~ z#gP6J#<)%@1kd{QiwYbk(>e4Z`gv>8S!Tm&zTH)<4$|dY3-|FE`lp#sWePi>6GPrE zK8}-r$@AVpf}c5N8&0~J1u;D`XksS^EfulYWo^Q5sCr;?F@gTf zTg}dR3VycbufX?A@bd4M8Zyur_)F0;UASo?03n>4#_Sy;(k0t zC5^<63xd`uI}jVzjpkQ|OXKRK6Oh}W#(NADpfP4WzB6|qPm{y(jN>W%*OZU3{`)w4 z@shZAo`An)BDB$XkBgZEk0iRJVx|xN%a-R8mWkl&3L`!wvs2(nkH^rkVysv`14@D^ zcYUb{V|}GKN)6yEI)l)3`x96abPAoHePeb96fteUamGfs;KG?FVRDEpKG}1+Z26M; z>`=ieNZF`CR9u@`Z(aaeWE2tB(#!t@ zwF||H$AQefI1wk_T?pRcBl+Ttbau!~o{Z3nAh-56qi9^=J?W6wW+sK(D z+*X@7H3}JlAD)P%%45JdTpd@BloPicc4eQ66)6Ak5iiUCB*)LbFZ<8_IsSV-n@=g+ z3HtY5v5~coV)wyOf`0!8ak6g_uTNB`iyztZq=C%>@9;V9yeh@V24;$j)*9iW*N^|7 z0S6wk!7OKWH+xbrl4r01)FD+9)^_=m`rV7Eu3R+q-b#Zq@Ml{DuaS12EH9n+2sSR4 z=h_96=(*C#JRrFab{u_$3ESGyJzk1G^moDY=R{Qh;yE}~)B_iT75LX7#eC@;J6`a^ z4|+9K;k2N?f9Rq^Q|cU1f898Kx^z9vp5uhwg675EZY=*>X~X#hMT5|+n59uNn@-K1fh-h@FSBBXpGvJ5z zcD@2cpzm@Ji<#g5zg zk)*|RT2VgLwQk|3a-DgW;~m@@m4U1La``v=7ktlLZJ6Yo%$DEO5?BLL)TqgWsVgee zi@ic7fUONT?B2_n%w_CpH}s=DkWIJpTlps$3yrKpL#szlWAD977uolKI4=7xA;@K|W4bi$1R{WgRyEp~J1K@M)YP zT^R5IF5SP%kAA(zx9*amZ9*>DIw8ZRaHJj&T>eAI>JGwdEtzb2hAjJib1U>bUP5nu z8Nns9e&T{D-*Kt&Ci>vTAM6^Ggu~*p>9nki%;m@pk*kyoU-K@W?N%B^tAl3H_v@;~ zO~JmXIYQ`Zx~+K5lcTt=QIbkbxx#Nt8qqrOeOT$dkdAGgZY^0Pi68S6xSrs%D!&jx z^=`KdHCQs$@t)8AdpwA*Zmq=m<2H$7V+F32=_QnxAH!`mEFewb>KzVT%6<#kdj9+_ z|FABNC*~$%h1X_G6Yhb)oF1^y)PRV-TX=n>9d7)gh4YqJv8OSakR7KB9tHONe3=n> zRM*BXF8=_7j<4qf{CfG3n)|T5ZyHa{&4Ur4j#MCva_KmEzP3q~*Qn`{`oKKAHfk?5 zmuW)BpOV}zcqTQjaf6>D7Q)};0Q_=c0#CYP!_8{rFk;wPzVo7yu;crH71@L7xXjHw zN=;E*bVrWgnHdkNx6gqf1LRg#O>pYI4s9H}lb34>xFeItSS;HE6OwiI@poiFpMjoi*qfq+0wIiT(N$>=-u;^NLR>WVpxU1Z>X~VYZZu- ziYCInx!S1VEyrDJyPS zLWh598P28U^)a(T8zQ~0k`-1qY{3_S@s;c(d`kt^#>ei#rOigvD>fCzrkT_8J$h`J zf)cxPY&G#Q7{nTlM-u<^(cEB?30~^>k5?bny)D* zNza+5nalD6w!?5ojtbQqT|}ZwPP5CeUxCfDC;0x!4fuZiF!*?dLf6j~Y;EpMOg^iR zzjp`mewjg~o9`2{}SeP1{R&7v3f?`3LiTiT5z+=T2~QfGhc|e)BtylI9_tV3v^G;!Yr>jT+g8!9Ay*8qA@Gz=_YlQ>9CN+` zy$u>eWx46l&3GtlFI}3Rggt47Vvq9K#MdMZpO07rzDc<_bn!du!@-91w{X`*2|h~g zEeY6gJc0zMU&7@*HgxR9+t4Px6r?2X!p6y0na3PuT0QYOt%wYQ5%b#KBxs|3txN`^DTlSzVijL7tyBvgeCX3hGSz`(JY6!ed$w|ggWW0eZ# z?|Ydk$#}r`j+rD#xe0nnIGF8MqGk&xgNbn{I&}U9??Zr|;w&*aXAQ|hEf88GVCue2B8VPN9 z{e3O*xKIz1zU9NK6+>X!qoZ&}t%J4gASsLjg&!xS^_I4qH*)~nI0CsfGXZ9~DQDjGgMn8Pluio##Vs@bZ; z_hDnT8XYw}9v)Q*Jm|%3aMZhqWo)*GmQ{IpNSNhgWf+qk(E<`s1cxuJ#C7MCX`*Qi zy!9w#59aLvyYRL6@wyUBR{O|A*;$x%`+bq)#16jk}zY*YX0Uw zfW%R^v2*bkbS^cgw*MsH(0q5?=P{9*xNW7=q~-bL#|%^4M)HG8-r%){AMB8%2*1YM zB%`{n^WP^s5d0%)*T8aQj|TJM>t8UUTmmJT0-t8z=f* zbG4DY|AhnB+a=G2omx%}&McyDLiBN*g%#X}Oq9}9=N5)$sONZ_+*lHW^N#nhk}?aX z+nYw7j;&yS1WnQO+8lm0)(ScWPxHj4rQjH~jCRC%z&SU0nB_hXeOunb`-@@lS*sqO zHwqc5kFEGiO2(SF*-d=j zpjG@@??05D@t1k+k*DA6O2m2gL0m0L@WiDX^7W9CG>trtbg&xaotXk-N8N>h>E8Td z{(cO`P~A?Mer|Z$-cusM>=un zs(Loy=T8*5tb*mI3vm0$gYd+<4R1#qlJ%v7(cIPs z8>ZmFoIIGDtSj2a^`XOS7%z*L=V_~x@wSV=HwuuY7H637+?z#Dw1)G2cJh4R6cw7c z`Iz{~pB6y_@)18zo6MvA{JCmn05n@21@DjNupw6ckT zP>&wak);LuB1rLG1M~~)gmXz-`IlF7vHRayZeA9_52a@FcwXsDUa`Ch46?k@ zXp0ZNzPVad67UI_zzLd=a2%?Hxz`m-Bg%(dgW0vcBwgY--*Lc{uew@~OJ7Ox>fOc| zFsvC%GPj{ZQcaoTA7!`|dX&h;FXkr2+8~)ZhTA32Vf>3e`iF(U#e;s3=r;jZJ(Z$4 z?ZA!qY4BOwMfAI!;LRQ&_zMGKP{nK_^yb!!cI{3C%g5X4>q9qCCRTx-jJ=Kzi>FbC z(m}kIAEqS{u_)O7QNcM3Y_-GizR6ZzTJI#xUQY7(sXIiidCBBtQa*$gYSPiOhGK%o zRMO#j6Kc=a39H8&=yv22zQ1!2QqIi;3V~ z@Y%L}{1CO973UVgl=LGQDCGvTS5q?Okq=+ERGXg;-oSS&8S@pM7jWzJvE2HY8c!cY zX-ky`?GxRB2QMZ0!Eq^~sLRjTCq-}Oay^A~j0g5-dJ?XhF$cu5@o*nQ*zKRrB=FHn zJd^eSEM|{Hn=QMo>tZiJ^tBQ+vQ$S?H#?y1hQQ2)yVI4BDX#gfW?2ecUvn3}+v~tY z^Fmf=XfBH1^%|-bzaceC!uicAY}BhzGHvA3vMWLcuU7n0I8!kietGO6w|@L0UmgqW zIIBt+c{>`b$C>km5*yH8m{%pGRKZx2%{;PI%=EqDpzpsM_|@kJ(7;RN-@q#(ZMT)e zTq6i(dcS~HkH@T{rc~sqdj;%O`q`l<8GIEe$tPKSBR8H>{PlMaYd1H6C58V)Ernl2 z`z}-bnKhJz)tbW6B7Y_|G!SMv+!e1i*$Go`<$}MD44pW&78Kt{fUnALHnP|s-|ftX zb$i~!LNJ2WmT}nSTLdrfXacX7qHp%cl9=UJ$Pe>gCLeB3Vs+cGZuw4r=wuW6N1rd- z^tVeVX;3 zpTj%#1%6iZEqth$i|hRkvey0(kZO#C`UgUO&<=r$*0uzlw_V_QG8=`=R0FhpCnCCCUQHtTVT5W(-8ehq%R1&(-$5tqIlix0k}BJ_{vBhM?s zpJA`?X!$y({A~))6Xq_;YvLhP^nj&)*Jp(lKcMqxgJ@BlBOe)QfT>?X;g-fAtmrl8 zNx1>^*wS0P>g_b}3YOyvALGj24vlAL9G>Cb<-CmQ)Rj0VjbQ@M(?=uYGuiv}c!sY5#FP2*dfLU7Z4-UJjqynG*krl(F`OZD0Pv|FVm5PU%kkH)A*ciqsZzg_poo;q1FnZ$@R^U%=GnAEE&cU+_-HC-I=v z6=cQdWvn|TmNXA2BOmmWV9CWX^vjw(u%Tfayzh97bLKt*LzNlWKDA!Rf1C(WS#F|K z7J@2=0_jWT8u)1{hR*f=;5qLU7(Oy)-5uF;_N zHw_oXeL~r7Dtv&rhk5I5*J;^K}J#;B<n{1Vx_21q1%0vM)06zM!dJBpbt5-y6!E_k>}k;K%Q?cr7q(4gp`| zL^T6X!V47%y5ryvu-RH`v$v> zQ{b1-OPN2m5zbCI_VFrkOW2M(vRYB|lMK%|6$#}zKA3S#8IOK*VQgVHYp95Z=yM9# ze|ZJ=>qbDz(olZnvp;mjT_ctIQ^aY74G@~~5E?8c@WAUYFg`S2=<&3|u}b0(_BEKF|59LS>Ozdl2AE!BMYFCdaqEI1+}pKTcwZ_o^p7W6s@6i;kvGsF zQ3Xq^HK_3#MbxE|+ zG%Xj-U0T5lc?u4XT#XCxs4uSlvTTVx@S)@)ObcI%_kwhh zDfhv}i<<0eRUDb3{0Dq?$zuPk`*3WwJg<-sp^^d>^}sd*tZNqbDlf}L4{xND*=Dss zy}-8Bvr5CWcJ%^7XC1n3sDjk01F&`CJ#>=N!P{|5MEk6#LFCgC_-}PCWE9wz#kB}r z*Lm;Ix$y_F)@p^r4Gsd=wHa4=_~DtJFzj z9m=ct`Nh`kj=c-~%nZY|YwigB;V?dBwG?+IGW_`OC@vlFnaxmN&(43Ez~6W+>~ZimJMoe89(3s6LuMu6@Tl}nc zA(>g2uk}dDt6oKS&GrK%@22odVM8BGaxOlQAJ$cQBZk(DVdO7PJoAgeS4r#l@ySo zCJ#z`PV)X%ORknYgE##-k8>X%AUVe(&?xpe?V3HFC;13D%+nUao$w%N?pwnf-7UDQ zR|R~3f0Il){1Og%y3v0NXM+n`)AXV_FfZ$jxS_O}8#nJ0at=aipDu^Nr#Oh~-Rba% zgODFI5cAd7@Cn;J0C=A$({%-XW6E&bk#g&|zuYl5v=iU&_(_cZgu&4}9w@tDAYFW> z2Ygm$@TS0vc=BNY4q8uH;_R`sWwPKmSTdi^5^`mOSO7g?dlD_&jNzuseERw01-$h8 zFiJ9Gekei24GiMJ%UhLqKROE+4#vTew~ln#4Sjm$fk!Z+6W zFu(31)%kRyO!=K84-RsqkH!ezmw_vJnTr(Fo$~?HZhc}MW0kn#^BHvQWCv>d`ZqK^ z96*nB<>8zr2Ts-+!MA}^`9~*TwtU!6jGfvi{#dIFzhg@ARmN-f!AiIE{N5$hahE(b zE6#&kGaX^vvp#0yugOc+$Yb^WYU|++V$?8Rf%eZWxst6P{_8iv;($aVae5qgS4Gr0 zTnW#*Cqmyv3mn&Ih)GWVFlGBs^jUTo1N6#S;#OIDsp$e*$i0W0{#Z84_#56eKs=BN zRM9n+`AraV+SVk(_FL-Qf6XwuYkDG#uhOPd*G&b{TX|SJ!55MnGEv;VoKG6{i@lm> zNvqbSfz;L0%aW!LBv1>ZLU8{j5RvRJ;}^KJaVJ^3m zQ{;tOL+P1e+feP)OSZ)GBHJPGhy0%kx$>cH;`yTqk6xG{HVG)hS0BRgvROT|c|MQU zUR5ABj{Ja3%Xe(AtTL~0ok@(Gn^8va@9aOdQed99;PK5vQ2zQPqIJVsWcVzAwmrOu z4#NB^`Q`yWPaSxn+Em_O@(8KdF_iotMd$t3)BDEp1}P0HNl8O#Xc1cP`#On~l93c5 zLbCVBN>hu{-bqTSB&(tKecef-BtL8b&WZ z!F~d^7tM{23<)?^avly_SCZZN4TPI3@~y9U;WSjh|OU zvvB_ozN6vU8J@4Rh5WeBKlgLb!l}Vul^?slg26sr__MjaQs-PL)VxSRD?WchjaA^k zpC94c!gJ(TSO5`j+YdHlqRTUz?Ejcms9r%sPi3+iq~>5vA!6-p9{hL<_y2s z7vhrio8V$}jc0s@)BG(Zkl7{=5NHH}jkz>%#R0ND))yNa6v>&8GGVgZEQs!# z0+KWMUdz=V;XAL9HU{hiLw>F^^ z#%EeXcHd>H*837aFTaN&Kfa^y@-A#!uZ%mce1~1H7D8>^FXEq@N-r&)f>Os5gR_n!JFH=^Dx1aY%#{O;PaW=nA%LR5Ps5;d?L38p(1|6};nflLsG|jJ! znmrAr<@3b3u#n3zXJZLe*#^;>>Azv%&v}}E)Ez!N19tdW0kj7t(01ip@F`*+Ikih4 zSN0)%tNcdB`PYz|_Y=VW@hG-4*O+uZ&8Ipo?pX6{Dt=u39;eyz9vAI+{B!jUO}wiJ zmM1=;eCH$>ogYcld)0WoCY<_CNd#H>Nw}~|jLT#z@$`ZzOw@HEs9%&t3E#!6+A9Q% z7LFo6OoxRvM41KuF@j@fMsRv}SF#TZw@8lUb#Dd7gZ>>jB^UlpwrXrvp2Gg@VzCQdppQ3EzzPiXZ>36&@FV4EcSG?7sP) ztlakl`?54yd~`p&@9Yz<|GQqeD^XjTJ&)usFCI5Hl2mR=T>Z=9M7N*NW#NiiJ&SLLrpuD zgZ<}GoaT(@`1|c|!qo<`haJ+`?jDNsGWTL~*=M{|ssj7!V@Ze8FgD!_!S1Ka(dOM+ z!OYhb$^U2EIt7fq^^h#=jDY5wH)vIyGZ*kL2`2L27Bk09FN^)X6N{quv8#bnF!j3y?$V2)BD{7n*3E(Vywc%b_bXB3_)1hU z)8+=YzQ*75jo`nW&k#Bx0M_y>;lG&&1Pg|oNpNNn{x17U|82Afk@l_F)|3N+Ky&o# z7{=T)CAjZ=FoaG!MTJ^oT*|62lD9XFey9Ao>Fn%Vwql& zmx2*rx8ky_^KfBcJ$~Fh7y7l0ar)GGq%>TW1#WDCO z^0fN!WVUK)3e1~n!n_-en3LjIjJqYlLLWtt`zkl-x#Qh<#p^G%jxVQbJlom(=OCtS zO`s;X9r1DSFI4Ct+@qEAuW;qPhiZ1r|- zU_?3%=(z}wZIw|{H5jKpNM-XEUcffxm3V}+#G{kPb9yrh@qOq7md7?=_1zR`?48My z!^c8@;02_sFLGyci{SqIlPrBGfLYwtWBW|hvE#rxwqgNSIn}raE)<2sQJcw}t4KA} z{LMgBat6MAX&^^u<#NTVi}Ay<(;)s)is{%aX4Aw>xNC(kgyWvBqC2}svdr}lV4wF( zxTIsx{#K=+-nT5e&bWD}QVO1`Ae~+Mi{wS_7mY?ln#&N4B1DDociNoh#z_0TbEbBJm z&TkfD-Cq_k@}--qEYT*X79W5kYnHS1vEJZ2Rgc5adUki;I~sO83@y|6_oOF>XGQmb z+mFTQJYJRiAyWy>^KR3nmO*@;^a^%A&|-N@9pKZcu}t7?0asgsxxmdGaO7)lW#&yS z_If<;3xDzsM}2C-%(4lbm(vJN{{sK_`!V-hW)wGTaTuPzn2!>V!`VyKe1Y)KT;^+K zEVy<|oxFc2&UF^eM*l+^oc+sbFxM!F?#c4u`*$mt>w_fPkk>)t?3}DsJlw&t%b#(| zdF)=|3f8UYBxv2mamI6g2_K!!1Otawh#o1*MK3qzVlsvy^^G!agNZCZ)S1nmG=iJS z@1gF+I&-&;t_xQLcymc>q_E(pv0&H2IDw;&3`~sarb(;kG7_8&%Y2nFy#ER8s-MDr z_sZelFI~v`AT;H@;*qVe`#XP~w;awoE+)*L`c4OoAnIxp}@a?W7pXT$TV!%Op8} z>2k0aNwRwWDF^gK`l-j8hRXKtskrFWMVzrfo*le30L=l$C>_W1Y}egFx9wlCwqYD+ z8*>ANfBd-FidvjZrwLZpNwK{Ko7l?9$*d&$8sy9D;5}{k@vhQi_^TI*8@FAAHFL~h zvUfFh_Nim{pdZKdcHo6ACTJ{@A(*@D3Vyd( zfmgqIx!J&koIqwv^I~)y_$kflE|bV{^Q7lv#qLq8 z)mK8m>y2pI?|?I0M7ff8t6BO&9p*T36WZyRv+bYz=zod2?9JEepSojJ*8g|w(iODLOPjNWVbCMpndr-eCmIZC7(-Uhc76y zUEPb|-=6tg%ikj`LA{i@wkmP6JlBE6*-CgD5`l&X<>9u2#GKJ#TFPE{~+LlAp$NSye6cqQx?9VC7_2=q>8_nIICVrMmWWf z>;!$-xS9M?x($VOVo-ip9!yq55m(;F%FVZisNq2Hc%sg3p9Q!acnU5mXy6q|JM5qL zfJ}*LgyMi|G|sT6KAqoib|A0U@S6CThQm}V!xBx>Z{W)bLSezaX1e5b9MyB?b%omT z&=qDxmaMpmk*!&zUFIJt;rFKZZx0G@Y?6ZSffMo5Gf4}X;p!+V zGm7GI=ht{Bem*obdeULiMdSF~)BGKdbYRy*jH_>fxHd6J^_|Up4we$nT`F+$c@p}! z=Axd$3~!)y(e1QbYt1=FPN^A z4=>INh3^GRptUFr?ylY-80u8Euy}d{6DF<0J)xroGa4tM`wl}me!HJM>CeJ-@%o4& zQ`mr_uJFf()70100W80aX1|siqUMe_R(mJzr@p=-Fyg8{?8@n;_Hji@{S7#E=z4(2?rn9~Z2XqQJxs!!9m9-IE>}JubtXa3X zUO#VWoU3i4lO17WY81h3&yuxqzV-~HQV+xWYAKKzIk|FcOg!;d^r7L(JydqlGkTrZ z?k~+Q0^5cf5UZ2s?9+HpSo0GozFz~gtPg^HaV$P>DWez61Hj`JFK1*%;#^N5+5BID zH9NeN*eOjzj3Tz!x#RfP5pa1|63H)K36bh+An}!zpzZc$ z>*-%&LFY#yI-e-P_}wa)U*v)ttB)|N?*aJrN+h=LJq0aFwlq{fNwC}nA^FyA@R=jQ z`RVcO7CsYvxU~Zk_W96-wX%FJEr_#wun6|QszjHOj%=<^H9V{;0(F%xG}-Ea{~k-T zjn0d4JNFt7-)JOXMVjH5?k?U}d6#r;`v4DSw2<%vGUVjUA{x|d4|c0uVa4q6Q1tyQ z=p7lPa=O}}nJ>vrbo(xxmTSoDWL*RYm!8DT&foZ67)xd3|D&=FpXqDg9hTel5z~9>F-3k<2Ar zg7s8tW0#hlb*giM;KIjc`0>ge_+1@O{A`=atl=fFWZHD=83}XAL=a_0B??Tl`2;ws zIMLF(<1j1G7$4NlL)&5_bULVmGkbk-`z+o=opzF9zM~-V=`=i)sR*Xe_JW>9Itq60 zLrIB3>$CN>w0>+cu2Y%_?JldyypleWZ8?K{$9E7>KPcGOe2v^1l*hZu1479gBf<5) z7{qBQfU`#<$?E8Uxv}{$d#Npr-xNUJ@EO92d^vKW)`LnPIRyR-1Ie_+TH^Tdm9X$d zGWso0VBXEoFuzU)DqLl_Zr$^!NbZ6CXd7-z;x=M=uu!03vAeQA>n>g27fFN99)@WS zVx;A58t5MwjdOc>Z`isyG|F!p*JZbr&pGvwNRa{>nj^xs8iv4&a~W7TM-Ken@m$!a zwb-#r3*?Rs(crJ1XkQmYG|eXAZr2G2#1&Vr(!|Cxo*&d7K_x7f!JQyqxUZncT^4%7 zPDdfQmOrE~?oD8uRJE}`M3jCO?V$f;rr_4Zlfw5Q(Qp`>aO^HQX0b*Z&y7ffBS!Xk ztjGbC?dDeO5ADDQ^LZ|9mAF9e+8I!Lv<<#o5yOkyc^-yC90~MvM*ZkdxRwO5)ZP(n zmtO$8 z$AYiIktLtt0E9xr!#OxvW(D4Fc88FBTb#o0$Qm?w_IzFfW_IbK7r&nuL#t9dR+HkP`i;0Bn&v}@dP=ZF%)`)Qs`vbA1N^71kHnY<1B$33zd5+p#6sS3CJ zyXZV?4N3ej9p8WZF2w2#!RJ76W@|JGcD9JX72{AGFq34b zmp0(!dKC;)dxvJSA=K^rLU!h~2HA2S$(6Y#^xw-#WYP8Mu>M67u?Q}MpMRpkrsN9z zP?g~H+O9*)#EY1jk%ab>=2&~+Ie2BB0Tus5@iNcAZu+wbIyVms$A*e=Sqa;qaNq(4 zUaH2V1-9_2su1iQZlU0jDA%9+4#Y06B=t%`pxW^o?mscd2p=hSx9t*U3>}0n@x6E~ zs{n__h0?+95IkC33bAG;sL9`Hw^UAL(XU$R)0#d!mA;&n*W{C@lj7mPwnFI7Ho_TC z3Dgdr1eYxbXo2|xaI%X4*VoU88g<8kWLb!Quo?MMX?(lR1zoIuV$)e^HcDKaxl~;P zy?-a*?d@XHzr>C!8RmNeC&|$k#hIL^$_%Ux-Gpy^{)6Ere>%AC1T{P|0JVza$eOLc zackU6&iSwbUV2xIAs;S5_jf`L$&O`qcI%1o!UWLTse!rTf5@^&qHx^v74#Kl5LY`} z$TMDnis{3!%drWjZ;yfK^j@$pJe7%WuJ#^9aGd|#I|W5@JZX`>#xZT$o3ozf)tshpqDfqHf?OhFTwfvxI+q6dXxdpYH{sA z8m_IewH_EsL*eIe%-#JK3jB)%^-kVo+RoivTy?jwX+sQ^6tUn68r(R4KR51jgeo^2 z{8Bh)xe|MSLk(@^c&*v&AqJFNq2a#s!c%(V$(+BxNOPGCCZhv9Y>j{!`;6$_Exly9 z+z3q8zElxYash@b|A52jYAQ73yLyHmLUV`+E9li?2a8iVMT1)KpO^wgCq81GS}?re zSxCM|Mlyr-Q(?8`7H-N9Np9o1BHXhv0Ihv?;9@gYMN>l?$pn>$Es z{+twCDj322o+XWbU(@i|kQ%0c&Bl#xLam0I2-8h;itenK9i_M_52dvoa zk0(&<_)nbkVl&&i)|rd8$-s{H@9?|)Lrl2%1Gha2p+mS7Te~P~-Be|p*JM*Geh(-$ zBNgu6l0nr;p@R0$Zv>998?EEsf1tzMGhFqfhx|BJ0oq0la3!Jwt2(97l?gyh+yrd~ zd1a<&4{=DarE`R*zr2oZxHb1orgt1I&AH0*Cwp@n@Yh=nJodbZRz?bl(Y+ zFRDYo_da+#(GsiAhFA?kLYCORtL1ZifQnN!OkZi$e@$kT=N$?X96H_8<56<0!k zeJ;GbR1ebQ#&HK{Dswk%YvHJ_6q1uONYI?GuxIxb@LstIU#5>^mxE-vHOjI=^Vx%V z>eyu*n9@cMZm!4wzVE^J*^)r?{NU!#pBSRN19p~{Lg~gR&@&2zg3u`Xqi8ZXcrF1? zd1;Vv66X?kq>x8nmC3>v4dl*go)?{zjdwIVKwBY>=jkf3-MggW$+Td4A@Ne>(W5TZ z<7z3||IjUxpsSk!IFpXvK*sL#7I!m(qsar~E1%*|-V#&Jz( z#(N(2NjKqyNOPFCQ`S0ab28R1=@bMXe@tfWXn+y_meSh~4uEoM7F2vcCiDxdMVX;* z=zpLZJ{wmOMR{W0A<;mpy2%m z_I%t3K7l!)>vI|8y8=nvVl6gFe+Btd{+0ZFWD8Q?-U;oi+lWnIKYcUL8?@r}!BRSq z>W@`mQ&&!B$L>mDf}w`+Y_$oE!%o3*XL~%Q^njKfI0-90P2#*{0-$p37978{k<>}Z z5_#c4sJ~;+`&_25{K-p+rV_uaUf&~ZZFQo1kI0j()OPT*jfH`~vmy7@IS@Ty!#T-5 zMJ-<~fmetUC#d)duEHA37=MBZe-iF%^fAcq3BmId_mHrwfbTy$l9%blSQh{=!fgy^ zT;ERrc&dVu{cm9Ew&ecpVRGPLDXvgRhtrSBvkahQb}xOe=1e-)@Sc#MZ@6WI5bt*ITH*sC2C22<9+4q|eP0|sXLJU|_qx*) z`}yAT&r9G+uMpph4btBmhAKr>*9e2Xhw;GQ9vn$4z~3Mer%aoQ5e_AA#=KT&6p%rb z_5PsMm}>lz{~JdVPfRW_@90B&^MmbUYYdgx3p`g*>^%<6tVOR^3$JzThR zH_TATXQkiE9^`Ix$$)6-C~}L-w^S2-k8-jRv_aDu^p*=yY}^D05Pl&e{neQ0IHA}uDLGn+UDr$Kotx*-``;&gZd3~DKO*S)s1$1W$C!JY z;E0b}6!1oQGM(=0jMHR~Q_Ic*qMo`0ycgB-{^V1bl(3LgE(s*Z@0H->!3=ui+%(j# zjKsB9d9JlqA0}?yiwD{oNS4cb^hkV)5A_}i-zlln+XnvleqnB6&K2^9gXNMqn zO9g7A8#1(;O>e82kZ=39Gq%NpP0KDqOTE{)G)tB%bDjVr6_n_Jg$UnU+DI#vE}_P& zHF(B73HRiF70xPY=ewMv(D3LolDDmf{vH*ATKaY%GBko)5$Xxc75n*ncPu_tm`A4R zO@a?TN8sMkLRz5pn-~U1z}6cK7DP*NY;h{A33>;M%j>|}eI~T6GGgmD_u%d>9nyVy z8#!rgikmEI1RiV*yk2#W%sp?$y-ZhzaJPw&G-m^w9A?9`_42TI?mvt?3uN<}PWb&k zkf;nU;B%xI;E{IWVtq{kYmfuv=!y#^~&(wuW{%gDaGZOE5PK1%IwqAC>E`w$Mw8< z1gvvD+o_U;rPKeQazHAMw$NZt6xM;qcqy)HM}oj0&lA(tOCjF$I?-~frN4}FsAOj) zoVqOy!zw9oazY_+_7o0HePL~9bQ2E7twziB*RbB*2-GCj@ccJEOZPE`)CThUpIiZM zu^W%IhWd1kvM%`MR|{)ZCGqpVXmm&=Sbe+;nxi`DvgkTNVYDXxnQjGpIDaz#nmF~? z9!+kn3dCf;(KtuZ4CK5dFs+W?#kR-8+^{+f?{H)l$ELG?b@wp$zzf93X}F>-pIm(= z!Ofbh%H8M`XA1e&m?$-cYoBuzmn8Fd;Bz;~xYF~&($UHg@Iji+b{j;SxupoJ^H4KO ziA5>cvc&WuwCd-3>Z1RmgG!~~*?n=`qk9rGh&*^`&WD?R(vTJTf>vEKCs);{vy&b7 z@Y}Ezq)Yw-H+vm?CLsZ?ztV-R8#U4W#SYvWWx@jP-NO3(gAh;{i25elD3v0@&9+-j z{`}s9sjo!%b0A4rky=CBZk)jpC*6qDtpI4BGzwPTETvQbc#?d6pU(^{1p8SEn%`fK z(%%=+w-#}vcV-Paa_1?vpQcP?#%+L4YWFJjzHxSV>Do6GbJyj@=6G`pgrdyKq#4}49OatEjG?bPd~x}TI@04)gQE40 zyxysXPRb%Qzf}`*H;Uqs&l6cw;7RUWIE!cDa3Ca)|6Q(R zR_GsG_R);*3LMGpe=`qU!xQ>tr3^$&nzd$oVuD`;sm%-}Mmf76sAeMZHGPV7!E)Z4{RL+kw9~0O zgIR4}67Lr|jy^k@=;LXtKqT9fEZF`NV-D?RwpO3u4?$8_uMk=)k(W-!anTi70+jW_BPVbM4~wY{MdpDzo>%eA3YY5Y0CqTnBN z@#0*;_=dYsdaVaacWv55n?5zTZQV41fAZ zTqdq%-?=iZov|HA+*U z_-H<9%9i8~EGvbhN7mwkp>?cHL5|BX4S=q7x=iHoISh*aiJy4>*f9lN*78LbLhk$o zvS>6MZvSMRxgrH50{EPL=2A$CG^DFXhfyboxupE>AqZbnMT%apf}Ly9p`rC65M{;Uqm>&3jRKOPD(0L$lrwx;NoaN~%@*o3{bjY!^ez@pib_vknIvYS5SWzYjS6 zgqS5hToT{s6Zk}xTOw!x8Q&$CY#B%0HPr|)kun}Y^X45kw7aV8)VYFpi)Mi z34chTh3gA27g6TAYsQik)nEw-(Ypx27H!Z|wVqhYs({$^5l~)ch}n-k zXzzdnv~L#R0HjTV^*(US~cf#<`h2*Tr9A?#I%sN|b=z@U(VbZOI zINyeotl_h<0e_Y&^2OQe6!*TYqxCTQAciUtZAY-gSh zw|+RVQvUIO*g2;fbyv+nyU}?V?yty<-lS9P2^&Xc?|L`3ZNVro)DLrWo}7CiYsLg%^qP zJQuhOryoo(2px_- zkV(GOgXa>(BsVb_O_MT(`z4j}mi;T<_xzBcy8+erxd83nry)h^Bdyl<=FT5Zg*!7v zknj9p^>l;R6Z&sxNO09@3^S-L#*)PS(EaTaK8Om( zx^IO<>Q?|MyK)mvKS>ksBbsPc`4cD0AHfTo$H0!TW^h?m0&%8$iNn*Y!aF9LsN{zM z{BOq&W@l~5jCVKU#sy=jU3CbRc96lR=VJsrHU!~SrCvN8vkP5Z{|Y|+yNbz_ z5$ydV2@}B}zcqHuJkpQOF)41A0NwB}Z7PYexT>@{=e+Eh<<9rYvt6W7-AH2iuP17Mp z8xFxTLuJleFcNYjMzX`#9@43o+em@ST4-q=#8y7fcyG`SnsR4=n`&C+&$)bu^6^Am z;`#%FewnjBYEyBYJ>N$-p#Wbg-p9q312{HUNY|x)$FGuF?7z03RQ{9}eaMYsn)-9u zvtt+G^oR#wJbxRw)j#ID-m2llsS;RJKaZXDAA$BqPlEeL3Xg`hQN+Oqzlri)r8h>f zC;RyLm!Nj3xaui{av8IqsUvJ`j&g zg1>)LplVbW;Rxi<^K#M2Ft3*-qNwu(_WUvNH1oZPbRa=Cq>zVl_%MF`JZ^k(2;u1I1c+S z#|t)|ae;$$5(}RH4lm|EhPG|nvDHL{tvO~we>rzx>IEgdzjXmO{dYJHeXm0q%h}we zf3GVW$3DYCO9f6NGJt~(gxl=1lq~yfONS%A;ik`DuuWW%C5Rg_@z^}-b*_>q#chOc zXK`+aV+7gl)=lp$H6ruuzZ1^8ms&0i!*=s1*s@LlQmU8XT!RwRYkq;fvt2oNK_lMd zna=fe8&|UH0R(sS2=1k|QL&sQFtFZ`d#hZBvJG0e>ilL-cz8TF`on9Kv3SaRxqj2m z*J*t3s5NxbF?{EJD|oz64-6@K0-{rV-EwNx08LoINomY~(c)&iKCpT7UQhCU)r&pMgkLGinmd(l)64 z*4dAnB7b9la5_#7Er1rgOc)Z4#q4{p$e4|#M2`21Z3+p-+aqV=ygiXZ_ALT?GXgPy z*Xz7m*J8xsXXw`+jeYhFbhLdSGm6}dHChYF+yW19vwbQ&Y9-ALYM5|ZQya*p)n8$| z;{mR?uT>E2u@=4NY0%*LQ}CvA2%gO>5uWO4!>M||NKfJ=IFXnSTyPhLJqCG0Q>hX!Sn+D|I)53&C{+SzU z-L@2L`Z5WO-?`zjoG+MXK8>j_d_z}%S7*i68(Chi0Is!2a$7??V9kSE>R2KN{yjJ0 z$g>iB5`LRHEO`TxVZCH~LM-_uJ%Vn3Ze#uAeJ&PM2jE%DKCHFxL;aWw_8N%TA!yItD>x=?h(paOw6ivYh#av% z>)ZmY(U}GHFM?2Zj0lG17-0NU3D&u{6{pR$WlDouY@dEHUek;M;X)lcoRf?BM3q@j zo52crFIan(9s74lpSj2`Vg~gKAzJqzxY}gGMt?=&geha#Yk0bGHK%xFHkLch!jjOXoZ{+vuzXsR@Z!G-T;@u1?#Gx(+(l;%ZutRQ z5@ei(W4XvK@1h9K-7pW0js;@Vt2kWka}mSV$&eFmli=vFZ1m!#t-}W>sO?z7 z{vNWY&+qudfQ&S&mv5wtYE9q-pHH${;zozhmXkAmal+@PCxY@sQSQc1b6Wk+0HsqN z(aA?t7&y$vn5i?+4fR=$RhV!<@D*eajN-2S_X?Cpir`P4ai%dY8Y}o2H*eSi7Zi%I zA%4yt@)6_D?GQ}#%fS@ITmhLjmu-tkM9~8u33}b7-={0H)GS35m)-+CKa)Y@+kEaVo8oixFSn|Gr4^v_Mw}paV z{=5%LL;_!Dj$+fck7Ff@9kfm2173cdjl1e9FeSef1D*^~mv($qc$W0SXvU4az7@?gHSC5VdD?=@^@M0gFTv`Oj;?KaW z>r)|xWx?YEo#6E=9g9n4n1Rq1CrN1Gp@9~vutP;CUaLzcD_j#=Z<>meciG{jqgCiq z5rEsrDY8lGix}G@f@Mi(NcqC4^iBL}oc&jtJ-$DSO$nOCB9h%$vZgtU-!Pr^zUR5} z9b2JisD?&{s)Nk92vlC^%NNq?0EJs#qbt4Uh8uea6x!uzLPzGVi4Vc*MtmwdHkKsd5S|q-B}L$M-mA_zHAh zxq{|DCD^n%!CbilKOfo{5X-i4XeG6uZW)}#4NMu$+Ukrk>$xNM@m3S0l*llf%~rUu zqm|Bl89_doo57l_0UQT;*FpP1O?e{G1mYIr5CoJC%e_7Wa`nL8 z9oYQr5-nUf3XHEu5ZMinaHZR7uq^sRL~h^!^=NQab?~kT-&f6UBmUcPZ8p5t8&6vml6uB^mC6)w zRhR`@jX_q0qLH|D(M`I}EKZm^M~(_MkA^)8!T8qT6a?CzCKKiqg8R!9aLTHJ52XRH z&W+<9bRNg_^GmU2`Z@@^X$>Q$&4IKE30QhC9%9U5$QnCmZh34C%n3>$*Tw!~Y~N@$ zCT0s7wR5Pjs2r-o*Tb!5eoyRL3BqY+(3$<2kp6t~RrIi+d36YlPd8BfnlWs%@DtqZRe~<3J0N?^1YX+}5fh;)=ebFe^L9TC?N0Mh)i)kC zUcXImug;}G;k*wuY(0kC>5>!)CE`v^g`;U1*>pjZ1|4uiyULkp@?8wey{_X!`wJxF z^CVF5BKUyUfh7ObK{Bsy#y7>y0Yw^QztzYnaT^1ldBf2;?;Ox@wXo@Z##zl7zh zzQhjwTd1*U3S08@D=zK7F6dn?$~oL@0dLLoXymt`GH3rN?r&)_zOFey_KtT0*T<*G z+uyn%T4*A0mZ-;-^TxB=FAh_)pAuNEs?8#~*~38U(*)eHAphuEo0xy#U1jk<{l$p>Df9?tOj* z>x4qwbh#5l&R1ZI@qV&=*DZQCcAH>!-*U9CIg4r8^>Sh(^r8-rSnK}q%zv~CI} zZ*{J~hBxZ)$|ehJPQE7>H-07dz5hslbqH)*kwnHz9;b6wXTgE3A~do^2cGMRaN}YP z@#B9o-0FR+Na_As+*Gy{5{Ec4s?>!|;jYtl-LEjIRF@@fNX1|T9RiET}^IiJHZ&oUSZI5ECcn)C84ZiI-IzYimwhW z#yw%8<@7^qxu9u_8oo|l zfsaWkHuuyJ?ZZ+uLwp4e+fPO_Ib$4rJC-c)oIp%l{Afddfb~t=VSGOOx$v*TIN|#% zB|!K)pq9A;CXXG%9$xW8U#W*URs-1fMq8G3Mvh$#QNnpN0A{8I!MS~H!v4i_O!a~V zoAgJKnzzo!-RpL<{d}%ovnz=hT;_Q~C$zZUW%}Ufb_Dg@IDA+RQAHe%0nd9aRE{G8c(+8 zS5eJ1gTj&tx8SDidEy_K1Sh>xV9jp(|8aDt;aI(28#c?Bp@@(~LMT*t?zJmXhD0== zR45H9`K3}x6f%>sqM|Zp$QYh`?L;b~5Gj>PB@HT-CY5*p-@`W_I1am0*9dvLXCf@r)`sUz`uwlo5>U9+1c#odp!J6n zc&g$b#%@`_GCwxcnVG)izr4+eCz{D4{S~zFG9Pl~Lg9$T9B4}}gzAcOP@e(?dr+@?Ee4A^?6LB=8k!g)zPTSx%57JI+!br2 zlDcpvvW85rawW%S+hcNG7(E60;b$W0 zZjK|rTG$FC>ZXt@wKHIP-Y=qOBS>sZ4Uur}Cv|~L!c58I_qQDM=x4#D^1%=`D&s8+O zSO_aDMX_h*JdmdENL9=7Tc6>hm z_7{c~n!-?eY!9?1xWcRYX8vC8ELu}xhc#W%ywThDcr%r>QHqxZomdY`Rj=_4gqlHo ztqk+>{uyH27KR2U{lsq9W?Zv*GQ2$(0~7CWMUN>ZkR);dEX-!GS6@o-E-n}#3+-FT zuX8p$vD7xwXLbT+t@MYCXO`4BI;p;~g8}2{WjI;G8_J{n(M3KKPt@Nf75|xI$vR~` z)O~|W^_0`z+({f`?;tLC5(Kvtt%*eYb22G*4^#!|!90B_6gs;Iqt4wTd!nb(c}0R4 ze^U*YKYvR`eaykGf^(>qkCONG)~Fq{jHYVt#ae?yn0`GB@xCByFOY}7FS=qq=Ra6{ zYLK`^Y=rQXt*9TBfIg?A@MixeHt1RiYr3f)tH0RO=C)fr>zviVJ1+owo2&^hFqRe^ z$|e)JcTn&?EBGQV!>*HIQ#X+gJ()n|mT#xI zCFZ<{ig|F&w}jrP^u=i@#Z<{s420P!e5uFX=> z;kFIn*W|Ky@&rqs&4l+5HV`GyUj=bY`N6*&7gXS1q z-z|^rL-WC_ZyCH3c}sT-T*fCuMWDE*nCN-jMSp`1qGY6lsqzT&ITCD#X&Ahf9S6@L zaflcjCLbR@Cl4A6v2nW;+uGE{G3j|c^VXg0)a*;RxaBO~UnGRl-+gHee#D*zMfM2B zV$g%FxNoi*db((`RZoS$OhASyx!B10MThtq>HpPxM{p$elpf0aTt)bW(|}IDOeXkO zkQ~=9mQ&B4!QUrq@Ra%lcEGe5!@37>ENK(#II$2k3{pXOmjmoSwvc%@eK*LRlE7Ds z=Q1{?C3GXlQFXA_ftP#g(dtSge(y?!2`1cgVxBm@?X8BYgIe%oT^ltiE(4Qm^I=Z78N{bw_ecN@qV(utxP>zM_hLxbbq7e?{ zHo-_>3S`ynhIgXpVe9Rm5X-9Ly1OUwdZGaS_kc%czRn>QVi)*+KZ40sXH&eLqlHeV zp3<%t;_Tyxq1gI;7JFY%hD{b@@!x6*kSQo5Q2|`e*5Dw!+WZh(bGr;TJW*xM_(KQ+ z2JA?=8~m_wh8UT(FzcfbOg{aB<27ip^$*Q3b*&YGZc64%~QWa?&mkr^XaXyh;s2G*I(i??P>kC+5=_Jsj%Qk%+tZDaBB4hwdwx(<8G z`2zZEx=qwh+klO*6fwCiictd?ty(WyCxF}mTl!=-4fn#}l6`dePZ?2v|AGW(O+nG3It&bZUhnRX z*uV1uUAZ#_ALtaJ&7?FcJU^8bSGi#7qiL|f+!^fMvT2FmBGh$Ah3bTJxP7G^mLwVh z=1KFXa_;d3FY@8lOkLiVpH@V^R~)WYhguGf{fAWjIepbJfCBAPQ8hvZL}R_VeNiW@ zJ`f8Zye2W_f+EalFOXqf9njkT4972uvwGaKBBpf?dv4I44NqOgD3n*AV4^Jw-4x@6 zO`gq$eJr7$)s5)o>))X}aT1%BwF!NyF_B5rJ(>d2Q}WU_Nvl4f#8< z;Y9^lDX^|9_hG1vYPrJ zJ7WxPw|^t%ZqGqnVja_$V!%1#>UgV0oN3U;6IeE@1Ml)pP{#ffdWjm*D=|OFc{>|8 zl;eP|YE9TRsgO>|G^CquY1;PhyqdeS=);wDyhVHV)=RyJfUT3QAU3uc zgg$%)^Xw7$W{dE;avdtle@8Rp!&H%%N4nDuvB>ZhzwMhQx9VtvAMyZ%xfy6;1IN*rrOJF%Sj>G*8)|Y{hPg585AD1;fMzvWu%vMdWri*4BT_EH z*?2EhnlJ&Y^!V_VDMQ)RaI7s%f{fZ+kh&+#dcIPDRJ=)z6YTlza@BN}h!9Mdy9D`j zD9Bz}3Kbev_$>Y# zc#I4=;8u!rQtSEN3*{*D@hlyV48eup4sm{Dic?od;iCFvdhb?IM+axr zKIQ}TvmEQ3`vo_2PGDDO3A2$)<=MjI&1}HSBqFSO2G%|?w|o=E-H$wES(PvW*5;TfyI>P=R`TU?f9X!GNWN|l_q!$S)b2nhcTIZd zWd%e=#gOHZ`t;CufI+DxkP_)gXJ6>IoRKPqOD^SssPsI#+>j3yqa7H*mc`X1g`sV z+cIc+I2`YpO56|0bMI;{Pii_$evE6wJ>E`M!EGXYD&Rh59na%=SecV=Tt2eGBarso z7X&e``#Ad*f$2OM;B{-Fd9x&jdHkj}HCfc4CWF1mbs9^wf76gYjzf6=9TDHC0-GHfy61kAjH-ouc zZO`7`$K_2m`tV(y2iEG}Lyz+}>5VJDAtvD{@2^oB`Sqa`E=*p9lb#Df>LpFMyL%-# ztN{opih~L=0t;Hd5RoM;Y`<^~Ub}2WkAz;-+8oOJT=bV@%LGx04Q7xQqlTY3cV1R) zAl?$v!pRE9XmW5Fd0ZGnEjJC&m$zR~jg(rD+Nz1`_U$Jo+x>~hViP*`K^W}ZcO0IS zr$E`SMwFc^3lrtv(^iED&V_c7R~NgI+XEwxc#E(dM;7BmKH{0Dk$7)Znl<=Z4JD_~ z!mF*v82Uzp)!Xq9Ka@;hX1KX>d@7EWYUco-*E-V7>ch|w9!&-JuH-4zR-&x32aftC zp_{cC8+=+1$D=ROPHAT}3%p2j8+(ZUd_fwuuLk~Ewu8Jo$DtNm09XAh_!?I8*k9Ks zvvuwLyyvHi;D3Iwb?fY4en2r!Q%xbOJTBAzb@Q;M^*Z+Cs0( z~s!;U;Zhu@`F0WJ_K^v^gLKCZ7JsUNWs#r~bzzTU__08V1{W(DQpK z8rHh;T?Iv0ne#_5f0GgJUT?(gJ~@@?<+3R2&rM;PO#K)#pK~nSO(Rb4e$gnBfMWvH z*r8ZLG)`S5kqWoTW#+z27S^7DC ztkZsY_R9d*8w=sP2@}9D?-;AL>mjPYI!px9U&6s}cR}i_1r%%4;h&Ug)I{qHdWC$T zop+9NjFm5#dLk3-hOb-5zJEt_PoE`TKab#*>_zORucfd`Ns>9mvtf&7C9?PZLs6US z)N12Wt`j;D=5p@k038#^*kH)hs(DCnaE_aa0S(k$w3XV8byL@Wy>wO08@l&i7>S6} zz$Pt0y!y`%r6LU*Tu()lRY=N%ndD)LG^|@w2|Ml_Cx&XD z!Hs(#{P%Y~Z)nI4q+b>BmpJ63`!5BE+rh(|u`x8olVfr8DDm?rTq7Q;g1piB`ysbB z0rF=^u%e$Pu+N>QVQXDFirh|sv`wLKbwN;uj`?#eg}Ndr0T)+4dT&mdz%2 zojAt!<97JhZh`vpzIgL*A>7k^gtf+p=-2z}ae1jJy*x9PGb@%!MQpLtl#t+v}o02`>HQsXwNt*c*{|dW$7eqK_?ze?dQ#v zz6)JS*Jm)|X*3^%Fr%Xh>scP7Mzn&M)AgSsZ$ zPF(v>8p8T6V!=@z$lbI71An>EImOeM%H!EIwyB6_FX+Y!4@C?4OX(mvuMC%e{YD}V zgu;KP0-(>i4UBDLK&+vae7ZE3|Mp1`v6r|&91kSZx_wWGfmJXm{c+aP-(envzgPs} zYgjrYQIBk^0-X@_jr84PwxW}5z36@87GZki`cNocky~lE$6N8ecqH)?2YY0mCk9>Z$9iMtCqi@Cs(zi$% z=kGg+MFz?A$eYDn=EfM?tAp|5goWU`W<4m$J3;^GebSpOMSktL#yQCa@p<}U(kIzX zTf3f+l~2v+bDvt$aXO2fxut_|3g04G6;9CiO?5>@mw$5 z$gw^h{Y%O0gZ`vW$b_{i+{7w~E+;-CmC#`}lkrg2AiI0kkiCOTf#10kDt^mguImFT za9<6w)vI{kaFV|*@q4|)mSx(j1E}67s}v)7TAv>62Xx&_wVYmH+sZ z7O&L9^%*8i#GYc9T*7r|?bb6>io40%v?#FO)46ruZY&I<|y@^ll1lcszPF(Rc1}nupG2YmMjeYqG%L9LqGTk6}zeyOQ?#JVn z8(z$bzs`&*QJ|B@S#XJ2%q$q+#0-~CW6Lrt=@sdR#O$FH@nADyzqKBFQc(`PgDZ&D zc5T+erjopGsze8-f z4+5?S=0fpGCH|s0N!aIB4(ZC%@J_{bwD`s4bm&oVQffzmtD^L_sU9q;pI!ekTLbPL zJPfW+FQUQdKA?gZh=-pA)4Zq)uDo`K<;pGiX7MeuGgp@N*)@PxbB)-)h7&=PB;vPp z1ANBq2$$WFLWix~edmS@<6m~B{^*TGROn;#|L$!tt>79gtG>>YxNs2rjMdog?s1&W z>|yUbSH*AZ3^3R^9h+`k#p4S3XnSfJf9nqc7?2{^R%t>Sem^3yXbLY^>oO{w2hwdD zMa`NIRBZ1r)QLNSW!Ox&2wp{H@<^iPs0AKy;e9sfc4v2r-*s)0uKV#IvoEt>T}2%=U2 zIx8H66MmLt*YfH3b@v6@Sy;uE`n950qX^9Bx+t%u9`Q77J+Y{J1eQO3VL8W^+xxT9 zyixx}%xq~9@O>E#>wGfs>ozI;@?QjPRQ*QP@+DB|-6-*xE5r(S6VlSjd9ilShYNXc zNx6eAlbD)Fq|`Uo&4|tb^SB};Rbl9}WIL3Y-om`L0E;VJ&+hf`R#+O|h2>cv!F8Sn zF0hC|Kcxmt5G+CO7%^fJFt!)rR_;e?d#M~WzNoRc?G)KwM?Kb1Hkesv^nm<# zJr~}ps*-5+0sc4dM%Ze{C&>Z{xS?5v?E7T(-f!Rco=BMBBnrTTvIuQ8sVyVe%mAH;*E-wjx_Rhe0q7Z1+Y2rO+} z&5By&;E{|5y3hC@z6u&7_nsP&(u98gi}ELAxd^vk6SQpH6)}PY{iz zQ&F`3Ec-LK9-p~$9J;16xalk4kCZgHyWNKI(7yrx^KXNTK^w%)pT{hyc|or8Q}E2M zHdI+;$GFT*1MzbP*nMCxyK9{uC^woiGsHDmkAF|_=g3!T8X^Vz?VYfHlNcP#Da&B~jufl?8RALq{?|ZN#-z|e^*0o_W?eWJ&)5K zC&0FaB{=ibUoz|WM*MR;lh^S#kMI5;z@njYGBi(w<-mP(-*yv#^}}@Pu|Jl7q}R|_ z<|puptv>5pz67gJ-oSnG)%1%;3_h+s2vdi-*}ab}GY@@Om&)5DlAHvxbPT5${J}fN z*3+V8x#WHCPO|4D=cEXiW^;l^F;=SuJGK9yjK*bLSmcT77RvZ{!aH7;O*r2yp^;AP z_{qx-+YTmM9zySIQ6gfI3a?cR=xAstR;Gkd)s)3Jr92h)qC<_Dz`37(2|9|I5kim-vmY{i=g{*Dp;&r509fAs6%T$DY%gfO2gq~ z*OqwH6^q5JY<0ZrWko)uOkvz_>cfml1|Yu82S4zpV%^g_SUbY`N7r)w`XAyo)RK_5W9SbV7T9%U!C(BRx`68F)G=*~U`kB=OK9fE$?k}--&YbaP* z*y7@@1TYj6$F_lGg6rZHGjkWKnDQem*I1n;NcfzNkO;EL>< zXgN^|*j-w<`iUoMjH-~BWxL?s;UQk-S!L$^k{Yo4#kqDDccW9M7nZup)qim^g&PO2 zqjL`DnxH1!{^<=B-EffFJIb;KEyr=>gFIL=dmt+=(NeBq5;*L8i~q)+quAMWGNPXZ z6Sh4eOSqls_{d`{vb4uFEl*JJy&QiJ#|}OJpD8=HIG-9>x06JfkL0sg3kc*@;0?~} zV5`kJU4k2do*w0Xm*?1~-%t6|E#%0N$$YLqZ~$INSJ2RCAtrnQx0hcZP4~&Cp@{Pn zGL7qErp4Q!n`kM2N8DDLy6!D6=TjuHxckKNPk1TKN_vmeBqQklpYP}g^#)8+Qeu@- z%-P^geZ1$6I&5lhBzE4dq|Z#;VY5vN##KjxLQy`h*h-M~CPW}tn(ASGmPrsq6&vv*} z%4OqA3u)G&VYI6xj{FM`GkPiTe0H4L7dha&M@-*$>2s{bv(KSBd|^~PH4?@gc!bmDM2 zUxZyXl!_nHxc<>i1@@V}Ji8&i3EmskL8hW1{IHt92DB%Gv0f%zw2DOy&#zR2WAddt zN7IS>rOCUzb2O{a%PJa#yIMimOprVC&IjS{T=Y+$$Si!@1TI#WAxzZ; zcb)Hrsm+SaCDsdTRc+u+%3r7tj-|(SE>r%XKIn05+;jJV{@F1LTe4N)bF3q2JfMw6 zYO7d-+obHG(S|4Ka2#=0~lF6R@P-%7M>Rof@=MhOpGHEOA^y9LS?tD}S58lh}=a?9D z5&5d?@!+xZ@MU<6BrPt3I-i5ot$!cZzV)TLVjt^{wtOcbriyd*6k#d%ta&2d1IM=C z#zRLT7Z?2>~E{zk$wc}fWZ*mkfH>W^U=s~#Xtj_UGb9wAe-0a_@gGSY|yDJ}#<)*y40NVRw-ppXkQ3t$cxPwG*(;R*Q+x z4Q2U}6z!vfVV<7}Q{Xm423pE6@Vo`?<*~Ru(1lI(kHW(~DtJmNg#GeJk7-!-ftx=! zVdD1b>^((k*1ap8z6^H)Gj10c*k6IV6Aih(z$XaHF@_m(pXhV`2l_2qp15!hEuJdN zcV<#hkK5H4F3N+AifcghRsuHV9EC?|x#Z`C7?k;H293jq5FLV{vbT_gze*)HQd;3U zIY)9Yh0{z?hPaPR?a9;+sz z!TZ=&p{4Bic|1JiJ4PB$+$P4?l3-p;66|*P2V(_)_!A#E!RP&bWY>b%IKwC#-!zM{ z`m6qstKrFbuD<|x?&WqXrPp|K=f={;HgUGwsR1VQ+u##-Z;$dfVZv{?FnV6InEbwT zSi40BEv~tO($fzld~FvF>{)j<_i|ii>DLkKjvl^BXq6VAc}5mf&pVQ zcx5cWrryitoI#sF=b08>%W5Wy7BzH{3L^&KH+5?d|Q@0ZIwlva~ zQq1umPMD-n+k!X9u*x(7*U zD=9uYg%!WInVv0_!s~ZJ;qv8Y7?x(oaV*|&cl(vFVQ&i6kxqwWQ5|^p%}#KUu7?Gx zCODp054&4};YO7%EN|Wnf}38Ej(TTm&D|%&n`Xlt?^s$M5=Z{&ML^nFSMc6;lbi9( z;y;-i(=nLw#UJuc)Rj!8 zqVDd{KQoMMQ0qavduOTp!dft?^#m_NTV_Y42iWK7anG4p+`elmihb~+-V+Yt-f$gU zUaSw(xE*e#am&6gTyC6>5@Io!HsJs*%9#qAT))Aa z`Kv)7yBN@&>vW(6Zq<5^S=YE+`Vi;k+UmpJa#)DRat_wte6tHar34ezgYU3S`8Cct7Z)HrI28HuvNuW%(?3eWnh=gmzQTZpSk7cig=eFrvj?``=FUPd!|KB*v3P%II`d4+ zhGEXhG5v{LhBeBGQT;KC5tsDGS`p4~dw3c1W7cIzIoXdtvUS)ujVerj#3Y;!%B)*q z4ywLwMzs??cra`N+kdMEd;>HgX=WO^MDD@t-@~xm_!IBL@jT#p*1+%mz7Sb+7IN1- z1G$Dwct2bU^3%^i+rCWj)pMf9zeQp9(*$ZItxr3)CsB>{mq~f=DU9mwrYk+Rqxjn` zw970Ohh5}gkJ=fYTcR29zy6DMtxdwxE&#|cZ6*AJ7YB*3 z3Rl6F zdP7TbDwV8}fVgdiWSa}0 zu*!yzQ~E3>v|WH*y`s$gcpY5fIRbl>ESa>XL)dutDF3~fD1;vUP1kFO;{qF9`t;2; zFtId+#z&bL5Oo7z?C1Ehs7&p&i$KI+3LN^70tJ_R8CQcZq~`1qY`wf53d`4*_a~6~jFZ z=5C;#J;UfzBIq*8uj77r{P~+QC&U@w`p#(=+^qe8=Er&*F3sPhhxo& zk8~QacZ8WNZnse-H%_-HY2!a$KY7hthZh@Uus>&nOqe`Id={SuU8@7IV__YTGxy-e z{Ee{a+70+CYXn{+^Qa-00~1h_!RdUF?|zb@gN z=56?EmKZDFR6z2y7lIO$0CJ4Ym!p^9Z%hXK)K-UO{?ahrypP`0b4SB!eQJ>sO)}@s zV*`9&qtzXKRBOCIMLdkimKB$rNo&t^)qYx8!3@~!Di>~?Yb zrlX7atKR`d)kP@wM;a2JM!LYg zLlE#@4|rrfgp_|H$GeL0sUII(0{3BzMG^{d?8}=!{jfYY5ug1CH@~v1i=No0e3M(Hx5;UrD2~`Jm$Z3#C>-ZVVm}HsJN|>pLUpvF_e(W#O_OT=-^@%@O-BU zbzTZ|w8S5h`Wj(=b1!e1Q9HHWwjA3FeaY<)INGoTh5PrXh`GgFct@pAuMp^H0z<4+jHCRUbxc2pTNvt*cG#S5AG0!8jF zH-&NXwPSXc^>4)HVYZrEo!#?(Zp5!tAl{_iMt3+*cK1f_WLq3>v;k3Py z?BInMIw%*6HdSLN5NFNaT(};2Nf|u1C~@Y*=s%ue)gzkb{S(X_33++D9lbi&@)Qd0 zP&elw{Aa-3t;GB3*y$eLWs;6`U^gyRdT*&=q{_@X@Ekn5is;+a+0?IV5nMd;inuM` zPl8JMm^Cef^8&Sz`GR}EJLClnrYD1<#{GI483uX2k05p25zAl=8XPUBl9H|Dqn{#7 zQQQD-bDt44&tu>u)B&nb6-bek9DHsc$KX^3pHDKtUbApiT<8pD3ywp6jTs*GI}FX= zPJ;IWHHe!c06V4}MbUF}cxxU+pt5@-pRSc-O$3v%XU|;1+|h>-%gZ#tco)9&@deS( zOF>5D706zD3e3F&P<=*}+1xcXz4-#n@50%Pe{3X%9W%#i#b=;rT!gMSZO8wr&cNfa5=>q+jKLkX)GXQic^c+JlwpW)QSO1$Nln zft0vGbO>F91qpeuyr2RD#Pcz#CIB;(Bk5ifL9!xF6-raHux4NiYp^;MPZZ5y2S&JF zkZU3i+~ca*|2gKT;%R;wnH26nRq^2 zSkXj6BHqy1!5eYf@=a9YXC9o1|3?aye_>!@4aV<~V3rih<9QWl{G>9Qd1?|!{ylh0 zV}e!rDsM#5{*e;8S)@^+z&H@;Hiw?FMKmOxPn6>FVBWS}@N7pjY2wbB!L{pX;e#AF ztr-OpvkSq;HDTjv2ZAia;$KCrc($5vOu=zfheHSUP%=nj2m#2y_!WAs>vMr^HSB+9D1w+cy z>BL7Sn#_@V;;_=Ajy}1UL)tZt;cvqc{F)+*Ew+W^X2y0)6P0)T`5{hV#ES%J4O zUEqrF7tT!4gX%gACjOOx;*nz7VBJV7zPIv&weRvh7fq!fHk9BZdl!7AqePBZu7?Y| zj^VUpgkF;irRSZ!*@SRsY}b|tZMy}aC)Evoxk_a0Z8^?Wy@LG`((H#h6WNt(JlW#p z>8!NYSzhg>D|BOXBl+~Ffv9r5;lf!KEbFHZdFESSL6UpYv;~G4X z_l2G1hB#F{1NL97pfhykapwL7*l!~QlbkZpa_Ko-I3$SH(TAyo@?7}UJ{uFRm(l}0 z4KRyEp27hJ?(W-<-yT_E@F8Iccy<^TE|^K8ml~nx%rZDR_ZUbWHV41>GNLQP-SM z_K43o3jh8@RL42ywNMI|1F9$DUpyHh%jwM1Cyj6x^qFRD1Lkt$Q6^;bW$YJ=fbT1t zK$GL~%x`sQ=Z+r))w2r~uEB2L&CJk%8?SxsrXR; zHImPD))O$xbrX0B*3#2~9rV>IO9)h?Ptn(Xzjdy!BOd;9vxN(6Ocq z(k5W-(Q@)H(g0!~h|rc^0=H@wf?u8uhICki-sAmnUoDorP?mx>9db;Wqa^EjsuW`k z58-P02$Yc;#sfr(_4_tJZEY&R@tY#kSyhe){-xx6Nq3zIvL zU>Q1|1kP*;-Ktkcbsjv%I_3`Uf2qxO6)EGtqO)8Vpn(6l;Tcb}R2S;zYk`j)z(Sci zP&tUXGyx&K#O=!d2qUiEMw4*n?b0Lg_Ra9Z*ZNRAcr1GY|w_JgtHRPHt! ztha!^@mhuFL6N!V*G-m+XQC=E3QudQVU6i6`YJ&K_1#sO50_^!NB6#@lgIABq`kt- z%uPS}7teZNtb`-xY~}jc##Z>RelA$t`At#;$B1Wi3yFP1*mK&Kh^W$gh*78^`(JZC z*>|2~-4a2@cJLGbSE~Si+z>{e*_|U*Vyn>gzop=^HU*CQEuc!ipWvBR5OvWEMA0ky zDD_0DPU+BkEV+0#x7^BW}fFfrgvb2VkP|6h|Der4xXcu=!{5i^H$Xn>cU@>f+eHI>`O~Eio^}UTaj3mL%hQn~d%NffaEa&nA^HI9^D}P^175$|i zMM|ccK=#vgn6f*RQ8?ZU*F*InqDqSWcvP2_fBTF7CVV+{u*!to2XZ$ZVA0!#Nw7o4RnMOUslMUAAyVe{VEtb@Qrc6H-CB3QZ+B^K7v#0~-cnG^^H znT?-*m5gPk9{y)Fx?eKJV zj^|&zyD zLi8L|{IwHBPpsnawwVmNr#7L*R0n!I)&-5ZzLiRd}1|3oirKL2J$`5SbJ}HAj7+$b;)k-gY8=LdrBmYcZ~GKSCyc8m9f9 z7h}$$bktoMjJ~IYm>CXJna@p6sqQOf=JS|2vvQIKDi!&mqx2d)8JUWg+&=QhJAaV8 zcmANXED^U}dP|0MZsI##Ye-S4hxW~?FrGM{;585U#G@c1=me9zWYNbo7KWc|quHNW zT-+?rY!1JP^ZM4|1mT19R`@kMAILdN)*Qyy&ewTs%acjv+YEA`vJBR83&!ceyO`0_ z17y*WrSOq|lgn`l;92$!>B*b~_lxFH*RBN4@gWPlJ{C~Nf;srgHVYLR%*Y1IK@t>r z1Vv~*K7FKv*NfaS^yLt~jPoX?PMPqi^DY60OLXqXDeQUI2JByc3^jAqSkZ+RYVX*`@0tXUZN;g_@Mb(7@c~t9jsqwtvIm9|@w?~`v=vRlx#C6!{#G#nPb!Q(|9+$5|bKq(5^?H7a8yhC*8k`uO9BA)8-g4 zPs01ajcT6nf0*i$Z6^&SU`_ieuROMWK7daBae7@Sk12c z^yQ4Xtc_nAszoltx@-b}*ooweRx#EGP#AVit3MU}q5eqk0oq$Si=7>D3gcU^pn_%? zD|yj`jnevni~6+cEq#qyrLmiIiHRd4zj8G^DTyK07q>8-!z#@EXQ41bDxK>PPUD3_ z1=Lk*!t$|mATKV#?#?x4isux-tX0vh-*R~@+oZ^DS7^a$XN9m*yoA`Dl!EFtaY)#9 zT=p!H_#FBH{Y3>NzV#(F*d@u_7Pvsd_h06zu9aXk+Ll1bnvEb`{+-?v^unuS+&TT{ zE;8Wr)6#MOboTpUb?iS8OOLvWuvH41V1`LO4N!Ol6%QVg$E7nEawimTFJ*Cp*K+0y zzZykS>QPkebA7;I9=+YwiMx%)Xm9^LPMi+OXU&Nyi)+KM=0arKz(#BT*CiyRuylQj$r-HGQ4`m z5M9@CJ4HJ;B0aO2ry{1q98MV|bH>x~MD7e+I7gj^_O#-P6S{0bq#zTjQx2x>itNN$ zXF0FWcILwJwX~@<0d}}k{zm&S6jD5j%4@X93cZt5UNDYEt`8%VzVByW9ycaejOH?Z z8!Bn*gBE)6zbE9OUI*-*Vo$r5U%)+sQ}OWD8gTY6=X!8AV0!ykQlhw!{k(h?%;BrC zLoYndm-Ydhzid2(LIM6Qw56-TQd^Mj_LEvkZF&)-Y3MK47+*CkVecU8Fl`HKD3TA%w`I(vFZ1EAs4=nA&4UMPRABCm3vfP+ zg*3hdohe#?HUXzV&$<}?9{z}H1?}M6>Q-3Oe3G|McoM8O%7RVTo}=dJsrd4RAzFBb z<5_OE(Gxxo3m-SK!wvV@@nmmC|2yJ9`)qp8;{n!n9Ak=$^{IBL9X(~Bf}7R9f{vFx z0cW45}*=sL%b)9N?d|f@W>Js3FbmUUO>~L>^(S z4GgiWA{p{}ts%u&nk__eQdFwUOv^paI4M4W4*`GKjd!>iw}vc1IeF+?qXEG^ie$%F zIXj%#ifiw3_xW?bS$WS=&iRl9^XB)ld*b-$@27xM@8*G&!fST@gX{SG(L_*lQ6WQ* zRp3Fv0#M0HVGYb5!RtY8&zo1x=mpgA3Qa}f!cq~^IK>>yf2@NOufKuvp`#cPYk*nK zhnS}^7kT$2tJsER1t_+*xHj`X!3_e5_<+@*S2>473itNE%JJLco;G1dvn4Ytau1%{ ztHj9K=%ax|0bU+hM0Y(jrsrOU(Mk>6J&T*>l*zZ*la9MmR zFqRgK-<)S`@TeYoDEs0=FEg^&|1ZQv#NiWwN(ts z;)cucbZaE9a<>54 z%Ii}&Ma}^i1aZt_)9utD^$;c}J>y$Ho=OLY16aT=c&}9h8}d6~=J8TSb&DP$b|!4B z+*-O!K8d|9tw?c^6%80wCPCa@QE^o#n>>6DJ)B`#-$xi~P_aK!LD&@rZ*qn0oOTTv!DlIIxa$i&fybLL9R%R22NyCF0*|byi|s zIqXY#&BpKSg0Da0U|jwSE`6JXlBUkM_1QRowv8WN9y)-h4{#lIXF1%{CWU{@C-D@P zFTuZEGBo{@H=g)@6nSR4H2cVFkS39E=)xJ;C~zG|lkdZ=Q)LizWjZs(SdV#L^b0;@ z4Dlq-WwCq3_A}e6a&W}-9-fwOVZSbXf`NBGVZziq=rjK%G$v<2Xvjv!d|NvcQ0NQ& zoo^VyQDDGmG2CrD#|oWW2_ZqbysC%(JeMoF~#iAj=+a9YavRi zhFzD`%VsNu;#j9Y=Lpwg#+F^f@(34r2QLv|M{zYnNQjFE&Oi4Ql@;zXp$B_dTd@>8$(_$% zd==2UIs=iA zO3~LdxE=PD)eQgJTKFqb4J-00ack3Qa3PB1{ZmQSS+x^%H~fY1qqP|S!J4i8md5@O z^2fZN-e`2~ID7Jj46cH3cCyhg=J3}VoOme~-xLQh5@jOPI6#Dv+Mdkir#x`?v_^hn zGNINtr z&z*8KqzN>V+O={wPzl1G2nR`OOpEpm`t;uT_k&SDxqsmRZx?uk10)B$EB_ zt1!`QRUpATkHZp=QFdneJSaMw1T#9fqsR2aaJw!Q@&%T_>7f+R-unXVM$%!u&lBe0 z@>FoG+0OUOymvh`%n45%*2J?uKiHiMX7cyW`ooqEKVt%71aMF;-?Gp2AJfp22VJuT zXtefAjtv}*ts7F%ucMY}+pwEw+V_xmU;h_Vv;Qb)#pJQ)ET6Jg-k0!erVIP_(lP#{ z<5#W1_qo%vdj;thljYd7BojTQIKErDDkSk2vsR;NAT`4mdb8<@}NTI;hDe`m6;1GiFFSq<+#gEutCxsLI3R&VACOmQ_~{(Ec4>$ud+iZ8yzBvvW2uiekF z=b9$a_#F=TDDg6T{i~x@U!@i^cgIA$-YAAw>2?_XIgQ=?v!6W~ti;YH7h&D{erDs& za{i%+Y`#xx47=p|F}!#G84lfg$JR#Z)2Kx)SpJX8HGkX$w*pqf5ZuJ;_N7?%RgVat z$iTi)f-Z%k@bS1U?0a#6l{I|F`_D%YoeWfHu)#QfXV2m50}E+7Gl439$j8?Fx5F*J58dS@!WQwIs9oV+Z5@_ZVf0x$a)U?eMj-0b0?PE><5+3 zQQk{#*E#c!EXZ1NyDu3%T38(m_1A>3W~&7|nVS#Z+;xN{Uh~-q=Pmf&PL=um=prLv zlFDltU&$7^o`mIjK}dXE!N}2ytBlSDXnUDl_`7RCez(m9qll+XcloMFzxD4ES zONf^wC`2y&ieiGln31M?y$}!>0fD`l%=2wMtkR}CXeJ-cXuB6NmX8Xt!R<9uvU@sQ z{2Pgj*IJ?2<2tzDi*Q)Vk#n2nVNHn}xk8TuQ-kOc`0(gmm&o5HjcRN>1xd0RS1SLg%I{L&Pq635qgTQSRJ}M33ryo z;;gMhd{*)u>k9Yr)B7>@rKAX0hr5AEbRE|%KFi)dV#aJ&KZMwD0P}W>qoNtNmtHDM z|2XxsvU^T5`|d}BnNvP&G*5&Ti4xq5rWoH;&c}dgB|0WyLzkNmBfBOJcLk{7w$TQj zh`SH-b1%!^zaW%Z_-->a%JwlKL%sNCM=s9K(t?PE%CIU@n1~rpXLwhVaDL+yvhP77 z$SIh!oo_~&n>)g=WTprnYKp_T-mB2-`$k-TGqP4;O%6VkSA;r2VKTS(AD6%S%h-D6 zgXy|U=czKms6NUteWwP{#6gX{eE%FXKh=QVwIaBnUI3Y3 z9bVF8uDA1s&-VV94a}~8AanXBv#Vd92y(NLp>u~|YGEyw9F%2djNE|Tjw?VXA{J7L z-D%stQg{+o3@d|p^zp(0Tz2mU_KW!8o?I<9I`|;7b2tOmrrls3y)i?nz!#wZtcAI7 zs)T)g!vYGtOF?#~^QN+sRTd0GLj6dFEzNj98@=(Cxk}4Q3mVuU$=lFTJi-}A&!znpcd})<9&^~*L z`H`H-tUDVI7iwn1zTFNu|KW1fn^MPL5aPtw;j-p&1NIOquD~YM4PeXEU^IMx5|7-< z!#=gUIBE1eds{7)%Si5knK#O@@nJf8OlpCbBiEVXP5o%}Oq!$+GcYMF%d?Klhq=pF!rSMG@V9dn z{(G3pcJ-v-G{s}g7Kz`??|^^T|7>e{*QPK z+5^mujb~t3$({YXZ5Q+LloI3Z&SKoj9=3B~1&sVUgz^)O$~DRt%fomo){I#un~1;XL|~CeDzjow z9;)A*#JY4@zyZS+w)lAwbF{{rc<{#<{j@(kkB6%8`}cZ0pA?UCbHYLQWCKfNvN6SD z4XZliA?tXg4gXnKL-_l26cs$p6bJ3SUsoeyDxjVtaHC}GOC>)^n- z09I~nE3yHzF|>3s$^_-HUsW_G1&fJS9f3VHrO00rb0i7DXKyxR@PW zreCpP$CSy3MN7w{ASo)(zIP)8HMI^?w#)KaMZ7ym*Dvh z6yEzY^JPWp6}fUoGLVO+?=_egBpq)0+-5zD%rJO%7N|y_X2-if@JrKEab(IRwq4;A zM%xomE@R-W17U2sE}+C?9lFWo00w=~qw-G%@aaWsjy%!@GIv@b|D85@H=&N5aCQg3 zIz5T3UPhRboxQvrpM^=;79WsVnZb@uZtL?q9o%HQSlV zsk)!xn=gK_vtuG2%yb1M|3didEXd`;#vnn~2=u}Zu%1z()@2 zE`+0Qs|5^i_k*}>aWq?^gnn;VP=A%TI9@e~21gayk@XKSOi70=zI7i@uc~L&ijVMa z9ybOR@h{g$_jJMQ5dm<$r%GN-CcIRdOh3CBGEFa2K>273 z+k8|4>iS=^ZC1BI>RK15pG?H`7CX+}lZbUqkvRAz9}XXThQk9Vsl8J;xYV3tdvm^_ z^UxN)$Zc`b)$B)>k6SSRTr9{9qev2{=>?ry?r3%>5TmWK(DULv@TuWAsdrw1oN+YH zowtT=O;|<^Q-JM$ca6zg6b3WBWBA4Qq8aIz?_r<8HFp0gcf1mo3aC0CTUS*eU)+qm z+xeWmRA9-Rn%2)AE%HbH%MO;x+Tfd3133Od&+126A+B(Jz~oD|G8e@CN#zASUQ>@Y z!xkUF{;)Vp^XGA(`$m{a&Zy;8{u9M<~(uChq`o>eZE5!)%Xh`x1R-pjCL3izsHy`_c3l&GwLNyqI&bEQq77;de@MlrPuu7 z`$~Ui_5NesHRKMyTzz4fNV$GrH;{i$jZAdGjr5p|a~E+kZzA zpKvpfif-;MWDv&+z6-%&$yi1|JA{4KxtsAETLTqb?{NL$L41;$0e4cquo(@9*v`Ci zGh2O z8eLNsgYHhIxNc?+n(tpun_GkVnTs#6t8L%$oa6aSxc)PyvoC^Ge)ks+kKKnWlb(V= zv@VI?wioW*ol7N_ug1PrE6}Yz4RTg@Gm7QXxMk^5BK({Wrq+^-WSbIPyP3~y{%->o ztkS?Kh7;hG^*vzp4q*030UR2-4Szk7EA@-#^50!F zsl9^-EOs*f-fcd^=w4pfXv&;n}brW41~SrRJUEb>9sG1CcTI@unhXrc5jRC45+Y^#RPz z%BsD8yqd3_B}^*)tKt58H#q#`6%2{-VSG{-H2P}bv};oE^~O|k>)R4Mp1KQr1CHX) ziw=y&k>6;!ArBv&Nn?Ip&&C;VWAJ{5BFz<@M!Q|PSzN3x=My)grR!v|;fgb`CpnKr z#yIZMwV{?7b7-)$F6|naNMqMdaBVOI=tjNN2>DQreO#8;9Ri>ygY zL_VzL?2dTW@K{TKF5|AbjbkD=q+VaPba^;mPx!+_Zc{@gzZ>9^!L z@vjc+e65tNPqQWkQHPk>jk(Oz98dm#lS5E&CdW?rIKa*{h{Nvfu6SKx3pZ1rf@>PR zan4*V5NX^-ciO09)sI_zPtBjK-C{dT^bltx{(9rQlI=Vl%~-HqI0-Y<4l|xSaiY1u z1UE@0^V^nrv8uEi*Qc+<|ANigw2;S`wpIsY<2eQwmw&pS{S96`)n>CRzQgQS4OUAm zb*SY;N#52?-KaiElJ1FFMnY@q@r*FnUp6?z{ud}kzg?B4JDs-i^Hl%C7|^87V2SCQ zUNbeXYuV3AGdRaYAa)qFGrg&9STMc`FD0D9!TT=6^oOUl%~Kh&wEhU$|I^fZN%j#Y zFybU>dv(cr)|y~zp{-Y}-{rc)ZgD|UX;fhyTb6Du#dR?v^A=b=ymbNY{V^n=tw*gx zAJoHx{@mWJtA?P$!=rN~Bc9UtXOdL&o=}eDD4dIy+E2+`z3D$O{Qr6kO zkJEE27SlD^i)qBbD^j*HfrK|}Te~`@k>=^5bzvqG>o$d@)h$U{Tep0{Ly|Gq%zEiX z6EdNyg%GoRWYjy*SFDG(tYP+sHN^? zYqM+lL|{11`W&&ho}zAIts<#JwoYfr$^*%~>4#R5J&FM^>90I)^XrBlFF|7MzW~Fg zY-eAlPQl&FY{*p0!*q2>6;J(-51B333j4muk%O9kB!9CFiL{I+Cnm+==AlsB*s+>a z?Uf=KcKOJ~qnJ-RyU4QA>C{OmoqQYHN|GETsgS+^y|Fn7+_)a(<+XO?!c%qf?&l7o zXSRrH=FOy?9kc7)r`ZzM{ZSw^Y)sN!r;#73{kYWD3h&$W!HhX4z{1px>--aH-egW@ zY=|HazByal?3A>(8#y#_((P@|Q_qhcmHX@Du({{A}^g*58a`2&>w;v(9KeiFIZ&|eHg9?4I z;|M9_&Sd`8XI7DsOM{cs(m;G))lssC2p6<+HX#D)4622YdYrw zy5IvnZO*h}rXOZkFD4Sbq9prQ8noY5qUWck!nN#3{&F{8QvER>v(&>d?mG?cmy6}?WzSxyW1bQF&svd zIVS6g$f+dMWdpc+|HdCK%5`4#UBqF9v2|2|6IHrnNmdxY#H6D&j3IlNp3O<1Z|6AD zIZ|)Qybb{pWHkx4rrOhZ{W2>1Th01#!D4)PKfF#Q{sRrMDj;HszSdDf6Y6I48P&bN z(MD!Vv_Z6Z717qRq+hIV;K^w`^7@qu#K1N*yk|!P=I4^pvyXD0z2$2RKEO$?DX8WVP0t!WqQ5`g zWj98zqDJ!1(f5flP2)Nd_qBCtvIGHneI9x8;x5B&G2v8ECNL?v(jOk0b?VXkvHa3iqRw$W%@Q@N_vnpty#6}Y zecM5L<^-qfT|5z7I_>#4_WifcEC;B?$INiDEBaXcJOr|_7 zCNF#sk-HmfA>fT7ou&AB%;I+(!GSp`1SX=Lp29870MyIeGk1i zaGh~@zKVp)Wn)p}B=YjWD|l8spF}q=ApMc2Vd8~;vb6FZnVyh~Lo4sVoG&3vo<2jD z@6DrbeW{f2pVAqc(sUs95M1@og8i_@p}}Ax`vbII;z&%W|<_{ zPmjvjIYG&)X;!Tl@8f_>6@+Q3z)>lCH2O6G)5#tBRBRA6J6F>VgQc|YRxBwLEF^dN z^T@0-MZ|EwHF>@y43(B=1G~?b2E!CHW?UBSO6TNW z!4kP6bkVbK==)|ICXTp3<&?)nPF|@&Nl_g%H1vOGD%S)c36f@S#%2QYEo&zLkF^Z+Hc-1jn$a6 zQP3#Ph$2WXsklI(P+W&8wC4L(^%l))xQ1BWT6;i0L~jY zOQ#u_QVpw9y#ETHvS$^KgHwMEBN=VT)<)K{nNgB-v0(;XsF+Rb#;k$grUJ^<283z% zA%nL4d@-wW-tjc9w>DfuG1r(pS4|`m_Y{dvr3z_}E+?CUL&(oRXBdeoP3)lL2K4=X zn-S{0!791UrH{P=+4?Ky#BQo5*vid-$6q{fec}PIeS8as@0-z$^L*(>Ga0%v?=4P0 zDNjR-ylCzxeX{KObN2REHBkIJ!ky0>(9O!7<2ERg3eE$q_ec;Le^^4?ighU6eVB1J zI>!sBa$#$z19^Pu4IKF-OUov=!JN&1ar%c!cDAS--STh`OfFTV$&txqu3##}?LW)R zac(5qU-px%EH$tytAz)zHPaAsHBZnw8xrh%=AwqS=bMpjT^4F3np^=eK&( z@P!}Q!xP(C6^@bJy&{XA=XSac=XJ>u7bP71tjC;{>EhfWN%YQzS0G@uf_!YN!qw6< z>E7_uND9^wbOi=vCk5A*sKl3{;a3@3hC&4ECJt$no*+V3)@7BV14O3ywY4qOZc&@ zgy2Hb{=tC>I(VMh$T3H1N^WBRgf&zzjO!CmOk;{0E|Qz?M2Lp^Ihfp72AAF~z=BQl zm_$u2nkXq)_oVPW^YP?0vZ70otc$=yyqKNqxX^nIAEGW`r1;+h=&*Nd!+-xoRiEM+2vS0 zCyd&DK1l_KZRs1s5pvnQlMMX(iJnn@WZl{QxR))Z1N>!lI6WFSj0%xO&eO@lzA=b& zU#k`cs!##u2^Jdu1;O{RFXVZAkXd`#%Il1v}&Bvg@SW2IWC6sjB{{_ zlp?9?DuIP2C+VKaW@JXnC~EHJ?i>lSL`>o+8ofG;Jr`>+`(G%oym*r`9iEJ{Up3fo ztp|aii{!3_7^~H2PE>QBLrM2_`g-6WY}lVfHonWIGb<7qg{xaguzCm^zoML8FV2DV zUzIp@(gZTv$ax(fMB%K6Xj14X1Cx#FQGKNVK1x|gX0{3-S?o`CT@)bmI1WhJ!D@EL z>l%D^WHr}A_oq%8dtuetO1dN7h~(CokqUuK`fa}#>iCIU4@XZxHlm8}&vnf+yl%pr z1M{eX!7B3Ck>l+Dvmm!83-V;;gy>g|8OScsBUaqrC}o=~F`hOPjyLwwJC~{`vwj-A zU>HuTsrI#8hk$BN9!ka!MrWn93Rw`mVDVxi>&Gy1^Zl_(XS^pUhH;h3zL&(W=p%&b>F0#CGo|EqdGNv(ywa5swkw#7jhQ zD1*Ab;W}YgvcRG;726K_kWWrgJjoiabF?&xxXsoglA_!mdsi?i-q*>f&*$zYb=vr= z$R470e1wDMA%r~D=Xmuttlrc-a`R>sJ#_RAMx3Qgm`MiQ`YuYhZaBjJH>DWu`4cc_ zg#hsWBv2)fKA3yEkQm&xp|d3(;NgR7;7^$w=4;HP#qCYh)XXCc%5!qdlwJ3Hj~X68AO0{*vSZw^Cycd&~5T{a7|(|$&C-EF(daN zU3q{x=tE>Y!a!l_yii~a22vrQrStDg9%heBGKf!X@{iAorOGD8^8np zTv^iOAxW0FD-w;i0VtUvM{iB`fy><7=s&nX+&OY=A zm`ul9JSgKG4WRs)*{YcWc3Sl?5@Csz!uC{5mE#pYx1qxRp7{F2BXE|LA{}iUH>gIC zFmGq@&&jlbSJPd5-ZPIb3$vob8G7V}u>m>Vx(S}I--l6c-MGw%;}a<@r-Dv-biUd+ z-aqt~`SNA}PuWQF+KddC&u8LMq-K)!>6dq@iH?p#L#3%rxnEuHt#7ce=QEiG z0`#xSa?0wbL-FoJXnLGes8WJEtr?CYi--hOzqA3hRWqUPl09vX+y;ebe&G#8O|s{5H~5{2=Q2wn zG??qtyPr8mEh~%hn;ExTz0^Q0oFZuPep}KKTg*3I|A1vbhS89T)95?%RJvJ2o6g$X zfb3Ko8qB>jLWM80-kJ);r)CCB`xi;pv`HhcunH^Oau_YX16g1`8OUi#@^w1LBp#)> zW~&w1ar`XF)-xjUy=Cxs^-*|Zz8xIA)N#pc3aL2{;rB`}Dy!E_kE|BLU7zx(_sbBP zoqG&|QlzLKRm87t@8@sHV$ko%Y3f?VFeS_GVGpkeVqDKaqr4h%x8yuryQkAfw-e;)9( z`?ay*Z5Y@OePC;HB-t{VP%5|z=&TiCRPuKsttgm934k>KO<0Yh)(lbjOH(*VCawBZ}mC z+7NT_c^;Zn$J2h!Xb3} zQ}o9rnkH1~oe|oc6+=f}Kl*Rn%qpr}!b0H+TzSW+c7ciso{yVACv5Lx4VCv$+ewD*2GN*DO1}^Br_Zug``JNJ3t;r)l zOJ<ZX<6{+%QX6NeGW~~K1FjTeuo`9W3k&#$4a6)fK{93NVCt`GQl6uF(LP4 zXcxyWPu(#FRxb=m?|NlC_UHqqM7HswxZL5^J%Z$@|0eRzQi}K;lfpgm8?ZPl0XO5$R&Aax#C=fA&^8&=6;Dz%ryj}wAJI-*Nr*OPgDnqS5WumBD$d~UBG!zRmrUUpfF@bvQ z=i#$}BaEHMf8@IB7%VsC*xell)Fopo4f-Tc{@Y=Q#Y@v5^sg80zPF2+8h#PaCO(7; zrO)`MX)V4vCdG!&Dh5q?0h+w=JroFcu-=|iNrBEHxZQmVCIzm8nDm zSLAXX?zMEVY#$t-{*Fyr`2{OgBkh*udXO);|5rtjxxNZkJ_Q;Y z=1h$ulz3z7q~KLuI+LcXMfF-jKx?uo@salh|1S+pCigCxUFuJ6*2i*r?+&aU*g#tf zG~wzgHKHZZj<*AxP_wn04fL&|BKApSQTSA%6e>%d`e)PRJ&E+R#2ZxdYR2Q6j7g7` z7+!k<}W-(1}i>1W&D4pfRxr0iI zv9T+NyiJWHw?^{098MU=M@Ypctx%Zb5zi=n*~|o%xKXRLToO|Blb3nf8-Bi(Cc?%| z;G`-?^d^45y^r4Dr{yYiV~jW5w$p{K+Sh?U{AyVJpL5x3RVq}u&yYU5B8XGtdf?|8 zAM~^Q2cn`QP;h<|b3~}*`X9E4CXYR#x5eLJ-V1rEv33P?Y@|f6`Z7}%{sL3SlQ8n_ zI`X0;mSh9hyZyj*xbH|p(?Tyyl}RHqnS03O&$h%N?=;)svWo3!UCQ{+l_&0E0N1py z(IU=UEPPs*95g$_=(+fk*vbl6ax(~86LpBH;x$;lvz3>T+rZ_Drm!93GQ=b=1TN+r zB*Xk}(){@uU+UpH!lY~@o4@;zZ!ad&hfaa`M4*HS>^ufxwvj~erXhLx<2_i_Y{BlI zM=)xN0cE*2*wKJP=rz5F60c6S+IbV37okt=KQ>|iG9ybRkrT9HmLu&AQpPtY50IbU z0c5e}40=@{ioTwyO9c)qk|(w@)MU#%R^Q))n*R+&*}zoDS+7O9i)BfL#tir`pCL-+ z=Rj=HWGbgN6@3NWX-{_o^NICkwc0;HMqMQFq3dB*{Zs7B7NS`X_mcf~>+y%$a1|3E&tHh~he9|;X(fBFSsYFq`4UCZ ziR9mRBRr@XLr*;2PG|c9HK!Nx+{--3aau>Oc)8Qm`|(&-(h3F#pTXr9i!r&X9;!1G zaiP5`Lx1IBQ*HvD?DECkQtmWL{1m*e6$S(2DonkiK#w~Pz?sIstVj4X!mCWBvD-ev zqn=c}t0qDd|GtHUA4^G+iUn!mDNy^CM>=oqz+w$AI^YsZKg8QoNxLi5LED`U zOcta9#{V#NlPP@{5YIW(bIF$HyTMpif;GEc1YIC;0SX&%(V6GzR)3U~28M3tRd_UbWJQEXjyVylf zD`?OA&k#~J%7(acesv#9Vpg&o4*b(4Q@v7iNI zOK9ombR2!$3NMCtliUOLL~EN6p7vNs??sH`gm4y)MM;C`Wf2tDrp&Y-AHe#I3-R2! zA9+q`btRVJk`>71yyZE+&JjMl^NO-2?}ER7uE~CJ7@K@uJBSqP{wd zYz(>uci9UDN;7`h#Osf-Ba6*|pp?&F)vzT-J2pk2wWzzJMYaCNsCm-1~c@bjasX`y@)}Y6_jqpeGN-{JAm7Qt!$RIQLVP4F3tDS5>d0NU%I= zb6J;El7nVzuJd;4cwoP~Go2B*icS4sPX7+u0VrBB0$xjT(!sez!M_kEygh+8BX2;S z-vAbG4IoBZji9+zgzR$V^6>9=qU01S{=BD;@O14vv|X6QqN`w?69ZOr)<%4voPH_ z#f?5$y^B7*dl$tvwnM=cG4|)@SFEs2A*kJX1devwv9d*h=)QOl1pcsE+bzhW`x8k2 zKQD5tI)$vSA7{dQQ{Ze&C_S+IBH6^`l3OYh=xkwOTI2PGtL+Q3YsTz|Ns<`RZdD>~ z{}z!+whruzp_O>!!*i8?dPF?QUu69jKtKcN%UIPV$`p)B+piy#yu~MF`VO0oN)9fT7}DD%jqX*ep!*V zzo7(!2N!X3!eg+7^InzLN8zK#vuMu}E?+T!BVD@VGA4$W!O4scNb_0BsGi*pc@|B$ z&S@`sd15+g{2D-(7XD+z^6tT=_7=|CFoz!8UCx~Uw~nlqJ`B@OW?^0ZYI<^rM}>_( zGf{yO_-|+nIL_v?^?U=ky=)SR5k3#fy(jQ-c@&yHdWfH#V(6Ty2Kd6;7~`|`=^4%I zT!{Y&{C4Kf`Wq4S;NE#uQ+X!Mo!yHo_GF^$y3OQpix!<=ox}JPo6y;_CsL!?H{j!E zF-GO=aVL z%^W2KE2)EoFnRlNG5)kV2$6qY<8Vg)^a4Oyaz66 z9;4pOH0rnI5*=I6a{bhsCPw#&5j}HwA@_SOhLjN_+9c8eS^Z{sQ*HvvzFmn^ueiW( z(cR?hW>wOAAfB%2_n`(S(($d>R^XS)k?wgDslvHwwD=+CJd!f4Z4W(w#dfW@@u3bq zrlvvtz6F6$iv=kkxx==QU&9F;&i6s@@PCl@brLidep=Me|oJRPpNwQ~6^W?@JPwg*aD(IiD}k+1AH#{)I^De87#ywR%#kD9%06 zR!0041<5(XakglZ6HIk(g`X!c;fw<=@b=&c@4r964E-ufdh#!WhG_~Od3FtF2(cil znoWNQxzqGV=G5@nR>n?JjC|3|gp&9zsC9>9>R1HfL>Wo0W5IbOZyX}e-wRU3iwj6b z{dTzJHGsnD`f%rGAkEaUAO^c!h*PjS^eH;x_V2GT)pG{gEw!MHKJPJHbT*|6v}jyl z7F|3~2^;RJTJIISZ2jR~h;`&mXP9@;h)in?wst*|RQK}j2(GMGC$3&~s1oB6d?gPXTaK)Ju7GBI@|Ig{oVOJ5PA^2$Z!+vzCGL%S zK8ZPC*~moyssQ%)Ec|eG099^XLLD_FfM~>7m)|mn)pk>l&XY9o`$Ae&(8sKMm2rWeZ{3nhwam*baMlPoSCBq0kVNh`Wy* zW!4(&QMpYM=(;2TLS+un{_jUHxHyA(*wTm_J=N*ybRpcfMwNP+ z6yW<3Jvzd(B{J1+tbE{8CihY*@zK48%T|>@_uvw`Y}s?Ba8(YQwxJG3W#>}KRp-## z^ea2uQ2|%-kAZVLgXWMh^xbe_#-lpY-7*>wHom`+0MWBGL` zxmBHN%i7Z*t);YUX&1QE3ZccJU-)bPD7%}TM+=Rfz=Xs;Jepd7^AE3wh>=lvY-Wkm zHy@(U=k9})jk_VDa2fwo@Ba*)i91zo6va&$5+U;xMF^1w!#(>&MWs|yG$0|;M2U*N zLNXSWIiw7wgeZ!8_A63}l&M0AN`q1)MnIm&E*DNTDO4Y6Uf&atVk&!E_yhdzdJOorEF8t!=)`vQeXL0c5N&@>qws**vA z;|%z2@nd98*+a_ZaCV_f3LM>B#)LM11Q*?p>>2K}`5JKbfyG@FJJIWFj zChi~wB2OUw7sBH=Mi{xu0ED>bqyMHz9B5m`D>}&K!{4Y9mDdx=_qPgU$$ZnAfnP^Z zxiE+ywOgJ}O)|legR9tDF6-->X3Op?)B$HMl~$H=7#OjC<4zR7As$+bf4 z#}6Ki>AOQ@)%q%Q-M=5l>U%Lb`4k%0UPNq;X9LdH0f?0{&z{b}|8_pd8;VQubF3q| za6^s^jC0*7^eKN|@^>b-egUlrR>8(3Z+MjPkn4X6KrKgMFc>vpYPmehjFw^M*w}WO z-0~hBF3B((`~2C)UWS?fXqeHgpGX!bwKFyoYuI;9<8brB6r$Q@Np&_iVNqBT7XHm+ zOP+6FZD*f^VlK=4>y9D~GE;^75qIG55iSFuCqy1y48h;N4%Dr~mN7abND2gYlYx^h zZ2jDmup&K&F-qMDx+`2kKt3PRb(Cr9uN2A28;VN!Q9OU?(FS{WDzMksmPChOF6+>>YdhySWxGG+@sSrNN>_H_3nJ%o{OAOdO5s1weG_ zHeTA7A_&s1;I*yY&+;l&sMOCN{Q5V7Y?K$*PrGwAP1HICtviz#+qL$jJ9rU>4;evj zu_oOj@QF1WN#t=AL_H;n5K8|aeqZB-2SCYHuGjNXEKK|bIujsuMHAJT1TB$ zKhvOJ58h&rSQJ6#Q5||&at{5=X}_Jv8IokV0EZsufyFKry2V(B)aI_ltG^TAP^~;& zvHd%*C2}SS4XlCXD(;Z9VE_;2=J1%^2_V<<4OVs=vTGhovd?emP?>N++UaNsQnIsf zS=~3xyxGrYMNko??!rj1QAn@-WJ9t+F&n?k}QJ)%qX+&?G4?z8im0Xr{z{hsp}>uf@eC|ArnE=1ogxdz9IB+0g=hf(g=B``cRjZK$H zA?h2KF=7D;R-UEe)JS5dm+AGVE9hY8 zXBaZ^2MyO#!wWVi zppKgdWF4=-Qth7*b!IVH#N7b0{|SNe2{oc}-Y^LBKz|gx2LgXDVbl_8l4%gEz4>s3niePA&xZ%y<0Zm1XcNb^uk{ zhM@gdF&axBA<1Gz^ihl(N}EZN@vIb{;4CGsS2%|Jwr^oF1`FUuKs3B~F_)&~jkCHd zqfx9+0`~Blp)buFL(9cs9;db5X)>piE$eZm)BxoFF2Dyf)|1S%2&&=L&Bm6uK*p{( z5Tu)+DETfHPRc_wjRSnWbqzRJvz|0&A;{f1iodmTsNlnyw2?Y9f%3yOFGwuDuG>hg z2Bf%M)DU%+%_aAOt%>@wP@;Lpgr(@;37bmBD^loS zjW<4GL-U$hM@46G*`Z@uMm*zt1 zTXp#Ipaldz<gG}mm~=G=2LcY`>_xvU z8`Ih#kn7dbQnZ5yYb0=LMhE+2P9Bykdowe3T*9<-dwB72NzA+%o8V*Ya}d-Y#gezj znGeCm{0NA_(^X~c-Vsl@w{tDS`(b9RGo$_Hj|)+l%ea12w`TX;Rx7{2*_fJT{< z7~%ev9czDzuUrFR{O^w&eA;;f)~p$2zufJD`#SwhCEpUX zYw|&J=mbu(Yr~w-RB*I4prYUS_+P0fDHCpjC&jIhzB?WM`93GX{XQ(}V2HZrv0(7K4BugnC}6DAv|c!+E$kxC_?LjK!lbI&k%{3co__4AOd4x?W)l&eKK4 zikAS3_GHnIIwLq~0}t$d&r^5h%c!688MpS&p@$+edErfQRtldk@s!Ry0G-X}K|o2G z{CQA}o%}2CJX4ljnAOGho?c4c*jI79CvWcNgH%0S17mOIGaJ6?vcg3PG?C*K;rZ1> zZizpgvm_SloaWG&fk5mzCrXcB+D**6w~(B+FL2BzpY1UU2QecSzP;&yDT(Xh@Ez{E z^I-zf8T-dhxY@w;T7Jd6!}0WMQ?yk<(<5lWzs#^*29K&*6 zpM*+4EFv6A3O=wIA1v>kud zrYoG+M^PWs-j|V+Unk;2(Lhie6sJp7qnHs_%B060rcnHxQCmQzMqi7&hW+b%U2)rPcosouU>OgV;EqWSZI3&FyY;srz?Dn!7`g_D?Lpo2yFUxqv>5-#LI$ zEz78Gy)&_z@rjA=IKlranLw6Ki-h*ovII_SBiQ~9)3t4g!90IlKIJjrN~eZnWJi+k zAEIcYbU)6|i=mtQ#i{K#1y~;8h}-(_@yt2)(68lkq+IeVSO-s`>Mr)=aZV0$-R?}* z;dQhtyaBdNb7Vhc_V7+VJjOUqk!B`pABXnaFL1-xTHMyt2KU#O;qk>8%=X7a;8*yV zjqvVfbMud)Tm(TOfl&Oics3Mjy+=~Ali8uoP&#b}EmVBZK3JiNJH*9F@t_0EU7=0f zdt!hFKVjI>mFRu(FWl=)VVk=I$f_T;@Sk)6e9wDZeQfzxEaI~7Po0<0j|v|6>AD#b z^>b*Ue~L=2|G;^3CPRpiG3nxT_p~QG^7qDJs>Jd8N2xAOJjcTiJ{@pn!5OyLDv0`U zJ&qGk`jdr`KS9UTk2Y}Zs?O69><@o?I`zB^w(qFs?~B!7Y`<-#zA~=hRC^4(gkB+e zzJ`b`KS@fe;;>zLm^EH0M0XgZa`Wpqw6N-?Z@bR0C7<=Fyyq6YDK5e;nUjRZMJbT> zZwDQ?%k{gI^fC@nqNM2eALf1MK+T0*iaMrBBwDl@V)G`j`(k_8j8h@_NpuO3S)EHh zugpicTNSu(hBTSE#f(@L7h(cmiv8N*hUZ`j2=>gO$2TU>Cw0G>fzVm(?6FjgTvmu0 zJq>tDb%gPu$C=u%x=iEA&E)gc`#izaACUTa7_6r&!o0$JpvZMm-BcB$w@wCGd8j79 z!R=Xm(Gmd&8(xGTqNlL!-8V7&{Z07t&X=Yg(j*~vlB8K*kFs1AX5dRC(&txDcZCN% z#qlv`u-ch(UhcY3)`RuYor%`0W0(u~F3Nc%4L(e!1kjO*ZIdpP4Dtas6nuZ%t zH~0kMa0Kb!(M{&;Jc-WZEjV@jHSTSA3#E&e;lB5U5PLwBMyg~mn|_|eDOa;N*v@f! zH(P~F(&Bik=I5#WomuooVIaekxC#PyENR9m7jk;Rf5hibF0GoLgp=tV&SUFNBwd1` zHv2i(S^AjET#m#3s(SY5%2wDvUypv*tYw51MTqroMKV{|g4W-x=Ow~&{M>ke%+JZB zQM3f)nbWX{>n!PR=DIVdZiIK+&Vt#TSoAo5mQHm@qJl!a_$gGv7A&)+LC2Pp zX|XZr_i{an8#)3r%|eOeDK{z`_!qY(dXRmg=9HegPA@L8Azvq?GqvuLH0o?7?o8;z zWBdJyMez_CoRT7;HHR>dyn;rr>-gisMlv^zA@#R+VBxiwuu(`0)@EwK!+I8r8#Kte zFS78BSB%lQ()9E_JK`0*la%W6m^}O)c@iLGGX-KbTUso7^jaI5+g2u5Yg;I zTT`>>>z@;deZh1lqUs2FR^9inTTUzP3ld+ecvg|95s%cZ zY+iv3Rq>XtweDL*wN^iZr-FqzrohnD*fet1@*g9g6TrJEy_YVvK8a6w_o1)k0%@Ic zgcQ$qg4a{*m{8nGw*S^cqtHauIkc6^h8&_yW)^!=;x(iH`Xjsfs02vty^dfV$)9lO zDY!&FW7muCpyC^~iEw>8Q@MUB-9FEQZgi6+_uS5~=SKBuSpOxKU%d%-ubRcWs*6G$ z?>vs_aBNVq7s!hh0?d&h-yP(Lnaot;Cb*R(hww`{C;>2?)QO4aZ$PIKPM}%0BY}oA<^rV}3r& z)Fmjfx&-gFKY(>a44hv~fw{N!$xDabbiKhXcEidHa^m~40aQd}Dq1dS!_Nar=uV7qt0fBsn~$*5?<7N{ z#SJFM8({m>G(7!42}XWih8JJHVH4-g?cBbUe3-hDSuy`Kqr~Mwip(^qgp)ArX*mnU z>*Z+E)!%q})g?yumo=?-9OtR1h!EKshQNJ5*rlP#thBa+1NxezoAYAjor~ay3q52O zy8UJyF2sP)4So8uqz^@c&%!d7ubg+O8W4_E4B{_SikyU&A;hV=WY=6}Z3zrSy zl(`yQ*Z2Y0eB}Yux0d2+qg(iwYqu#~T8+Y_o;P}60jSYenB=h*6RWSV7kABNDk3j1 z>8noSjAzZvpmZVhMwZ~e)eOl=Tt<#Ao=>*izD&IXVsKafLwKV5fcZD~4IB|uq;4&f zsJD;^VHi`GQ5TC3IF4Gqc{jUgLN9wSECG0u7HqXYmo@&f78V@wqiHLavKA`SF+ef} zD`%;p=s-ITY??~`G;6`To6*G0M*xI9S!N{549(>@zuL@guw$Y#4G3svH9r>Bbo@Qe zUcc`EnnAu~an%fRboe41%*(_Z*{_*{)#~tF`!&D1KaFij{Qxe)C$PU|5jY0ruy#v@ zX}60M+i3C_^H!MC{=6w9P)&~ARdgGLW&ADmA^`u z=o$%FsNqeemzYxD*fjblBOac4JHnvU2dI!!A>lhdpklrtd;Y*q7`~89wU*7{b`ow* zE07}B=a@12DVwO(>xpz^xi5P}>l$3NH3%_(E{V}`TAv0l;l9tA0n9B z8zz&7*-CWIgIMHW6JTv-AT02@%Hud4$P=GP$gmDUy&7hb8AYF*|d}_d@_Z5hEzz6;g?pV>=&2hl8+QB8p0lOA>VAvUi$x^jU zd&+sVRXaq_ZFZmsD@x#klOq0<5U2(A2;Fqe2wF}a(?8wEIBG}fj#Sw(Zrwn)I&Rg)9xk`-w}Um=35FMEO;2$ zYXArK(CSNBvgE~ur8FWH^o~v`*NMupH0|?9#g&sFxA6xvI-*aj zWNwn3qk7Emo^ecF7z}>VaVW6Ul^&`2%=H^>BQti!qo#*5UAu1@vvSW0Qs8hLww%@? zF9v_WE3HR(SHTWHmdS&M*$aODcQJb9=Kwk#)}mgKK_Fp`@K!JrRqpI3(mi}WZzL5? z*34oJ{8J!i;x4jR&71y?{7iL~Uc%tke^7gP3UTq0CDxvr)OoTP@d}rr4^(fk{QBQ` zLzFY27YNWz&iSxpk~UrXJe|DoTTdb#M2MngAv{aYrnX+r&hpR!va~5 zr00q@4Y6e7&>oVQo&sHx)Xu(fwLK6dKC4S^S}v}Fv5`z}T3 zIq?oQtP8^_GLpbgtYW|Y5uuqq$#^$*H(INN)jW$yVy((lG2pNhu0FbuNW9O$o5O1C zpy)&z`BRdHw<|zK{9}I2nPS**;tTZ2^C8mgFe9U|0#Y_vVETilY@AvG>fGT}Wse=~ z?Dd0e_a+C7O6WkLz6I#@(1<>JHV--ur{cuQbj*rK0>wO0C_f~KzqEfqgi<$dR(r{t zv;P@C?X3|x{Lh_#sQo(xT)qTD0X>*uG7V3;$UsE05Bt`3Hu_c^$Hd&*Q1x4q=$=u9 zryE6RoqrSSc;YTP+Z0-vZkk5(+~eWq?5Wi1h$2-z+rzv2;u^0!Xp|RM)x}KL48Wet zo~%IWcN|+SK;1Z2*Twl)@)5(DvGb6b(7|qB7>{n{LbNip8>g?;5$`jqv|-sx*O9=QLW5S z%3Z85IfE_ZT=!3ZFZyspbO5BWYHEMjyM6ny|6Muk^OS~GMw<@q{Q(Vs*P!GgZbmv9 zhE;80Ov0!U+~!qdfwKU{?|6vHukV9IWgW9p^fX*+;Mhx3KEYX=!Z*MVPZ-7E z7N1er>?R70pafyg@{sRlM_cxJp|RXbm{=}G+tqHN_tV|PRsTYb+Cx_us+|r}bPD|x zB~M4yX46}H57J=g|1j;#Lj3740c#!(u{m#skmsX8p2?}^6S%syUX{?%mRCyOYS2@I#weH2$yzjmJ(iC3P5B?lkC zn7J19P|L-hn-fSgmn;009K_BEG@?EJTz)4!n;gC)hQr3|i8|LoIp<9eBN47gB)oEX z%Q&t1&w^F3UnH5CRVPFg>qgDqbxMg+6=&~|dD4zFmKZC+f2RmFJg0wZv+0`}hoAx4wnF z;Y-N3#{YQXVM!$W@*+Ces74y#3p3;NApgL^Sm=Cl9e1@pX4I~!kd_lZC~_x|RemW! zbe5IzzILk6mbyHQaJtEA1lTejZ-@9+;S;dk;t!^r-%C5!68f#~8t93*g4fO!m@#KJ z@%B-|u{UyXx3nA&a(>K5Qz<>BVM+bwcd(~VEQIUJ26@5vtV#X#U^K52$DXi>v|(~Q zZ@sND>tQ0rNY|<{+EW5)oy%fs^>rg%_N0xKDd+MqvsB4l-9ENIs1)2cnv$}S(;RQ! zfbJB@A_DR*_%`_mT${TZ%4V6-qnQG9(y>d}X`#=!x@4lQlPEn?HHp5y%jwnjqBP0R z4?DjI(xGjC@ZN_DoNxUy-F{1(&Q}%WnVBTPi)RK@XFDI2Y7V2C-DRF53DxKBniuuXGyzxU^?~ug+7I_3yzZ**AKmIH-Np}IW?qdr(Nk|Br9Hoe4z!d8T z;o2nI;0n)q^cX3%oJ@kRq`?_YBeVf4y13*c%sZk>SB;v{|I*Il@*9N!o zLCjS8M|d9guW^HaSMxz#k;~C#SF?1fEZMpD5{jssp^HsDUL5aa#H9|hn_O>lFJd-kt&u#&&@4kuXt!#}Olco!^X z>Bl}kq;f1)ok0qH{*&<2DHHl6E}W_P+sFNnXy9q>>D1cj61c1@qTl0R)@+6O^nvmo z%u%qRs`=%#_ZG*jWaJ6o^#w>wm!`$Hd+^$qa=hxeoE#J57;8s4miO{>?%sZos*2`f z>v?%A9!RrFY{2P?{o;QN_;h^gfC*X<(Qtz{}5ymTCOzZE0hm&i0vK8Rt* z;<5Ps6She_mKmHD2yeFk#QX_!>8;R<6n`7is;m(Fsm*Df4;GMZ!js65y#n!;`vD5) zP4GkIB64uMDXb5XrXeY<(0g8;5CuP`JvfFLQF9=wt4?wGSP7D{F8jva-c%TwwF2w; zZ{eirZu-ox2jtd%gw@W#1R9#L8jt6o%#+(Nc4@v#{pw&FOa0>>e|Eunl z+{U_GtLEK!ZAOjkR6(g>Hn~(41pWC+RCA{#)zIBb_AM<&lL~9PX>%DK3bq6;+yKX3 zM-z!JcBnl04AXG^08>=pNfn;ALezo@Wd9RYXb)LHm%mn|cVi@}&RuI7K3j|o=1c*n zf2+`X3fFNp_fpNY{d%xYF&Sd$PiA|h7zwyHkyz~sgMqYu2eXmPuO+9LO` zSNT17%!(k7zsv#`Ls4dr@@cpi#PKPoSyBDb5*$7%NzX6)fh#8z!??U2X?w0tn`euV z#7mo~v#uVPAvd4>Y+zc_t-+Y<{k~WzOZG0*r=I=I)ynSq_+st@%qhDHi>04pG_@dx z{i<{)Jiy50VD{YFcxKEr8w%AuFqxcdx>Eic3Twb49R<=Mo?V|8G@n6~ou({*W0-VSuwZh|kuD}Xm^ z1ImUR!N!Ir+>td0FJ=o;L5-(u(|Zqmn(+i5M!2BFOINZ~Whtnes*w4g%V9#I34XOL zg=M$jvW-(CaL*HGaIsF$0vL8C83X?FN43QC&BU@b8 z!+1qG`dt^JiCx^T;mY|0BI5XwP3mZDB1`&X!Z}T+5iZKqGS|;@xgPzeOvS3b&(?*26b{WvFFDLDfEzbctgS8bTJe;S>`GXb*&o8eoA7AvKb zfU=v$VD5=CthNrvvvrn+!B8MaSGBUD0tqaC-y5t7nFN|@&ta+TBHFof6CBy-L()Zr z>0P_6Siuu!7bPEK3~uM)vl9XA^Q@no*TW8Gc&D)S`NvRonHnxsh{hi;E!a`6*J76C zJ6P$M0^;%4V8b3eERm5RXT!tsabKTB)|EUuvu7%IJ6wm$6SauUo-XbN^8tEizrvo8 zA$;iDfmf>D0XtoiXpFCbU-ca0t)mccP8?#|!@KCN`2(!y+*?fl9V4Q-KNXBNU5EY& zo@AooI@W2*T2xh^L;e(Ju}VSOWbgcY?9=R8%}?@IT0ZGKZoV%{A9ozb6)v&x(^dwxEjH4$7d5CEu^(49>);(v zaoFj+4wX3NXk}#-E|8F*haNs)`~#-q$DNww>4kOZ*KrV}9*pzWyy{@hhMs~!j5vw7 zBF*kej^lFbskmj$M6&SDFgP+IBta#gvDM3lllq^q*}j{($zM;_o=nDwY3dOBOM*uH z%18H(HMmzQ1%#Jv2M@tLB>&hi{%PX^kp7j2%lo|<{kN^mx&IbXL4~(CFHD2y?_bIK zFQWXJ4M*ACH@aEZ%^f(K>sxZEn@E_mg}7kHf6SIo8bnC9jqB5_X7*z}gpRM_*9~*| z$II7iG*(Fy)6!06(Mu_M->@Dv3rnG=QW4@s8bR{VQzqt$HfaxD2z~EF$va6F=dISJ zd)6SPua%)*#!B@4Nq5+~`VH5YXTbQ%zQnXEH~0||_nAv=FEF)Nn(khc3CBK3(a@NB zR9v?ULrf z0rqxSkry3`VD$Me#JlE0<->cBvn>hE&T4~n&8@s`Z7L+5)10-~%NQ;jipRV8_%ODf z-8rxiKSyfQl+zn%Wndrv{qPDCy(d$1;VE>&-8D3X+wp97uAuXut)})$Dd;Ds!`8Xj zf_2sy409~Zp&)lS`28>?ao_o?zj57hQdgN5Bgl;QuE)DQTu!E;sVWj+k|{GrxiKtGf_|)8(+Yz6;BKd_h&y05a=_2Ca{`!p@EA81tZk>#h^U z@Unh*`e`-M^l)eSqq5{yk0a?ZJw$nW59xwK2Z-idYqF5jV$=gYF(_1l^!}>kJzV}D z*&H^LvAGOjyHJy)t8Jwjv5Uyi0xU?Md;n$NDiElmVY>rwd^9Bake3NsFR6ZvX=vUEoxbSl4t z$7yHq>&p4Cdh%R4X@?F|VxP=#jm>ayYz6i7jfL-FinM_>1m8J-@Yd^0I)DB;rqL&g zzTB*hOBE^Z?ee0+)!T5SbSmy_9Kz`DGgyH+w&3|O0hbwGr#m7Vup`+LygC2)%^*Ig z=8j-;b2ljW=+KA99jF%Hi=N08Bnq>9Veve1nja@btK@WW;CmpvezKIh!faab$((L^ zmPpeZjL72+xwv>vHpY#-Voo)3yBqH-th!}~dsZExC$mq{dq>=G^5!Qf^U57<*l6l< zJ&=anUPC`Ups>lm0qmr^m~UO{Q0T7)>Fjr*4~`Fm)x8O{Z1P;_yt5oc;0(zP{s^98 zmgK>dGF0ytC(iHevGJf227OZF?OT&d(@GtnXXyi~aIKRumq?>8zKr4i8b{i2yN%l? zxoq>PKCTq%3ZYcd=W6eZNNW0mW1ht zk{QY`;Xr^tj#*zLa|;S6uQ-m(oVb_weNZ`VsOc24}zhhp^RzY(3FH#gE!65V663jSm+0U;hpyFZ-9xO11 zx`GM#Zao9fEv(5~aXA>V)2#mU;|O=_1kyCt0G?|eLz2z}KAZIsda`9;LV7nl^3f1w z=ajMgf~TV>UImkmPyD9cKX8wNJuLptvHCSG<7$EJ)MarnV{Ntt{+1;%-<#@rQ9I_6 zPh6kSZZ~OiV$UA*%zV!m(0>WJn@+(?u?w7DIg_k3;&KiBYsv7oP8>UQ078-tv1!^Q z_SVy3v{B4K`LvDLX)HiL&hEqAS8d3~&BtJV(Hdgg--6bE^>JFFEedVaXr~)qtIj= zK%I*W@eY?mdHb!O$!=62+gsm3+2K0;Pe7mMpRZuee_K$?!Mkj5fjZSbc@*|6rub8- zmKDu0B4fKNLEy0ytUSGt9EpF(SHHdtCsmH)lPN7&+B$*0i@SzcYQRXY;bHPL1@?{4 zI^5m!hADHu#xC)9LhG&GbS-}cy+>*>*|;1nyN;oemOW8dmSgK|xt@^ck81t|KEsQ; zv8-X7JEPbwMC^_qN8!n9Fk3yAe!9s0uXa3S{~0I4^qDDE`#hBi@3Iuh`cEFK3SU9q z+QZ~drYpE9Z6N;EQ9$chvc{h4;mtfpLhZDOp12^bw0ELoZ{DE%%NUrr=NzjinuxBp z0rYNJ5FUgGf(VtYRkEz^z@$62Dh-ZVX`AlMCUziOeO`ZRp2n?|jY zv`Ga{h2su_^yR%L=(gA!Z%8HLl*Y}Zwem3&wr~KRwcW!zO{$>kzL3<}c#{d7cg*YD zbTU&#op$&Erx$vWy?f1R?k+{%^3TzE{w+qi>A@bz4=5cq!4w_q_h7GZl)IQ zk~m9QfSCVs#+JY5@J97RC~MQCqxPq8(==`-a&Jb-QX6{myc>1jVTmDQ_eh0BDE+iA z40StoaDlcvygGXkw3g`$5oUlFA`b6Tc!6C~gHlS?Cf=H}#$ zL@ro|GTRO5?KL04wC4e3Bz@@Ft(8!J$`@A2E#Rxz=Yvl|3v>0i8QPV&@a;a7P^A=a zs=j0keb!qHm6E2+7rU=`{MA=jlOkTz_vS5c2o+JC5mkDo+D9 z?StcQW9YprYIIQOHoR~C#@nQDioMxoNbbcKp}Bz>&N9j4XZ+MA)&_Bm!-RMg?olE~ z%?jBB%hlZPVj~f6ea>`6tN^h#ef)Sc9pqvwpzQutJim=%V9yhfUZTq?%5nVODOX^1 zM+qr8Vrq!J*WfdOiD#_e0{)$BTRY z<8kQbR>3&!FF{+2*Kn#`ghVT*fK}lSM(CV1u;m2wHo1>SzxfScZC5xt@WI-a97837jmH?s+3(GSb~#N#p$dZJ>)?i zE?wb;F2jxZYUXR4>S@EODIH}W?#W=AS7u@oC}WcK3GgocfnI`BX!l!ZI{v2wk5o3J z-5d`lUuXpF>^I?#uUYK$c~WrU;1BkIpBa(pJ`MpMzfe{#j&XXvn}*m;q)zt^<9zW$ zV0y6(7xIo{sDCA1aO8Skoo&(P#$Tr9jTYngyqEpTPM|Z3-ZNVz)0yM>`JgrIg}=VA z5WlvT9obn1hmPFmdLl&Vcf*g&ZR>7~w25Y_&9%snse+_o|@sA(YtmqyazRX+>9(-LuL)R45Aw&H^e3G9pWv)IJRgy3)u)=J01rK$_K zBXJajf6k!N&3Sn1Y9iyV%i^oEap3#oGJkwvC-Y^?UF^QmjzMuMv~IUEb1vm3w-;~0 zES2drPoV;XR^8^0y~)JP%V&6Q+-wpIbC~l?MVFV6- zM1R)@H_VrEyrrMqJW_oFme0QhQHu}b$Ipwv98aNB^h!afKpYp{)*|1=n%Drz z7uC-feF2b2;WFV1_+R6NsJekXD-yAQq-b)!m!{|N#&RZ8sGWwIr;=#KUY2tvBs~!vj$)S-7=e;1a37B*_jX>ysoON@EumS=o-YSz-qj4uxMT?z z;>YmEJsmo3T!D&ff5V%v;RkVDwj^BEFDh>V+%ew5?K@QLU%QjpcW#jJ}Q}e;! z5YZII=9I>>+LA}vsZW=1S=M1}+#L;at8-}E#JN=M{vrDM$9kgMzJSa;pogoLNAup* z-^GHkzaaj4Hr0G{kfzf((vslB=%!4ef!-#xjQftYwD4*ic-uhBTAKPKoM85al`t`~ zap)&|p{7=3KD~Xq4K&PKcm+)tsddI2`kVD+MB)x$vu-rJb;~7z8?W)IHlD>kPoT0= z^7uFJ0?Yf+0ne1z!`EXD>nsb(P20yWV5Pcm!64j^f-= zZ;s1;A9kuqd(@m?O$g>E|&a0Aq>vydBiDC?WvyV8Gy~6iDBOq{H z4;UE_pz@<@kS3!H3M>0zCYL#T{W+9OFPA5)%pG7=pcGxq-PbaN%E{BaCPbaRh)%cE ztcuFa@hz@WYDdZO6k_;0l)A?( zrVmqoBY(jM*mrmULEs(a+AERyOK-sXz1P_wAAhU*TTlUO(7G^^R7MANEm;8$Z5 ze@rTz?iaMg1Ivq%uWdmbqw7JWR*A6!3DU}C+b>V!`b4CU!6CYvIMsyGBgzNx(20vM z5OR|FC8t2Cl{#BwewV!&J`Tk0CU)IeilbLf6OS}IoE>n74KMShYuj#vs@f<(ssefR zoMRKD?ZjI*7Z7zPO9#}QBG@G<{1ec;|DNo zS}&d(cEhB{GwAy?Mfm6@2gbtB@Xk+LH1h1j;}(V3KF^#;1kR)`A%aA|VIs-OufwO` zg>b2uCYiZVk;v8sLC2Y|?Cf0q2YQphC!&~DTq{WCto+XU%2h&N;}`Z-)GqeQ z-m64UMU2KU8dS*j5H32G#|+J}B6m1lZSRX8*z93SmIzq016dKQbdVZ~CGN-PDU*N! z6?$?XS*d>Nz+%u^6h`9`ikFjnI8qhW%jW!$*G&2u(@zJP`P}Wlozo^%Ttw)6S$q2Js2IGLmV$>Tx8d z4J$+m&$&)TN=ihDl!VaqrXf=E}kpRbwz;+Kc;Ej6lCf2XVjCTM&)&Vd{w|z{=YT{&mQ5zfxgOb$cVZ zIMk3dT)T-j&xL2N$OXjzzz-PyD+q0$&*gSY7;M@^A+7TQ%0Bbv>v!G9kk~KKE~!ZM zhaAHaEo0*qU}04_Gy!tGSS)$(Zsn!nhAjiblW4Y5+BXxbdH zx62r^#yprjDqjstww}Tr_k{iU>)E_{_9}4EQRLUgDAO+;9oRfHn^&*X#j5WPqN8pN zs1oeQn{pnoF}_Z;TjCV{oIecX-Wl_O3R2Xqe=$*;9s}|^{~=fX2yq(2>BD`3dts_9 zZBz@!l2{WlGzfl-#v54hBokuyYO}E7iF~M;A?DwSU~1c&U^6XYOE#Yd+0!F%>z_>| z@b`O>&A%q$XHx+_ChDa2@G|oHIpVm@pOAK*hNH?30DWmpQ|lO>`FNTom72ijr-?A6 zD}VJpbvhhd#?6rLHuuyz% zG=R1Ud!1Fyxq?UA5udG3Y^v@ac+a(jk7Vr3X-3C!m5^xT0NEoLJYIQM+_p@T9vZ(LcJ)2O;6-jc zX76Ht#IXZs&)5bl{v?4x{s(CF`wH_9HNlx@mGHD=42@oU5B?R65Z~S*$uCF=y%li- z4wyKcSIq2%o5L&N!4wl1_Wc`Kcwm{Zx0!)8C)D_7m4o=TbqIa6?Fz~ZU9M0egA?+0 z0M}IOV$VFRxRbIMw*NEdLuCf?hcnx8Yt}tVb zHoMa}9_1U7*pH8Ma6|hMw7b$o3YwM>YBUV3_iCYb#%DO?^#L5=3y2SGn zf#5=IOzzdAQ`C+_{>pD;ib@{HoVv;ACr_$=SCd4QI6)tuBQBcL0JeiT=q)j(w`!gF zLSr93G;s# z-phvA`G!1XbSX*=&LA@$hC-M6Oma!(JWgL`&DO1#q1)LwboDud?#i~b#8+@h85Y2S zJtcJMe--5JJ6k%hB94BZ-NYKUx8R-)D!kqGI6v(C0q^iul9a!mz3ULzr}di%WyKlcZL>3lc5ly8BSEAOF($|`1KZT?vp{vZRUygrSNUcXtstug=U{eX-#tAHh9d3waR8W$Irf@juc9Jc5= z`LQmJNgl}Jo8&YE*HRL330CAfRvW3$Y%Tu$_5%_(=P=J7vy(gT5_-a_*Rkzfn>aut zkR(e?<&sANV9v&1?rm5iO76UZi~H30xfEHfOWaI;$A%UC(5iutW9H)QJMAbr7k8{mfPVsmXJGXbcpS0?URszi*`G^rrHeG3HlQ1pFTM}y2@EvBv8%dTGz5bZx{%zt#7ux;8FxGK007bLf{ zZ$CA#>wXhnicp64?-)v4?jwT+yud+AmlFSVuKaA%GCFIo0i4MD&c;htGsnhMRP0gX zJ13~3vr{$O+f|G+yL8AG? zf@@{HHF+X+9Y%)6gKD4(EtS?l`;L2LXXtn4)j11GWL&XT;A$N8`VSM@beWw>EN%?E z0|)QDB5vNXY`jegiEq&=j@=p$u{ue(>Aw^3;kOZGE}`PK=|jNv`Y7^f*JU=~>=XEN z&V>DHE+E$`R-@y$bbNXG7-kLA!#5M#!SBa6HuupnSYY-6Vpks!OXi*j4}C)}^Hh#s z{o90*YvgcPhbEsSyv~N4YpMwiw6&lCoE4tq%2EDu8qSH@ z39fJ+8!ngQm4JUxj+Kf%iwC7e3%9A>6w0!v7Rw0(PF zfOHK+j%#*FB@;GqLn-S0vEbUvH}Peer}?;j zSDCbSFTy2DuASw{OLj=$rZ6|2GV3AANgn0*2aMt5*ifG2vK?<7n8Wu!EoKEuL%3pv zDio{@01R0KPvY{64^%kdhWR=;%;Y6LE{s5_2dZ?~IY05Y2SU5^o`gV_ZzBha#n3d} z2VFbMajw57810zEJ6*TKm5+_sJhB2!S5Kr*A~(RThPy(qbrfAWFcDtNzC-w#seITe zj*(*qa>cX?wp~YXLY2y(X81V%*Sk~L9p1&&v#j{G%YpbUC76%vZNk|ZpqWc)I`)XvgAu!1P5Fvvw!f(2(R1eu?14EA?B_ZKmEEH21O*XMHlX~ zej!Ue>FOqy@g^FDRt%pg_-xKks=_^=G9jm^OJGEQ!e2HfJU1|uS7@H$JC}O#xq1^p z=axCVv2o%ni;D53od{09`~ez|8!`RM1U~9z5ia|)8;`L_4C<7}k3B zoZgDlOYJc0)^c!e8&0SH{Vg(Gkc4OE-bTyM$H;BBmpCOq8Y?SR_`-D->|fYe?!2W5 zMk(0wA1B9izh{EO`_)LefAuo4XtgPN8njN}y#8hL{lejl`em}Euo|55{z8hBJbJb3 z!HSOIH2Oo5;CpaGySX-~_hSX*xX;5~6I#)vbvo&~SB*Ur74Vj9Bg^~I#w122;<%w& zm>_WmGe2$T%g%lQ3yHh%%zOq6YF2>*p|42&-ZJrWySHM`-*55J;aWcBfgh^2?BZ8f zIPx3wU*HFa#rP!W2pYz(;IsE-iV;s5b1)6wBf%s~KSgqTEd~7g=L2DIZS+f;C^wSMq_e5et zx~xck>QnN{M98Z=w&Emq07Pmom)H-A#mJj~bErjj(%N8ct2F zf^UVNN!EiB=4BIs;_0${TvQ%-><)%}-S4P9w;NNlC3uyrsXu*1 z7ZZx0W5ZYMz7ar1VG+*Q=Zspj>)05JKA5a94zAdIV=1RfARuH9x_f>QPnK#I9qT`Z zwd=G6dedOay*A^vg)Vf&o54IIxC?q#4&W1B9N{w-JwZ5oo_u{V1=cRCM56zl#6|pv zF6+94&+7v|Hcp4HZoRm&_y8SRFUgGrx5^HIQ?9fBGbtQ?mdAN~!*uiQe6!SPu5;0z z_8#uVrSmDy*woHud7Y#W-@GF?&2GcFy_;#n7%NPFr%Y9*Simp8RLD@&p|_j^_!Yfw zambezeB+yfop-kL;>V-7+lBSuY@xz~b%H_hPdTXXqIhzv18lE62_JS2q*_8h=dV?h z*y^tVxI4TTZ)v%Tp+B5x>pcf7`wYy2*UffF#N|;CKK^FP|0F7jH!s~qQhr?|_-d?TAh?rcwi^?qS=h3qnRcmEGozwjv}$qdJqr8h{O zbEkO3wsf}tSqZGU9EMx+&0xi6Pnc(|L^tGp#QnO9@JB|WkgXUGnnV-iDs18E5_w2p zE(LktgP1;^E?JrZL(Yn0zK7w-+HPXn zYfKk3YY3j8HQ@GL1~v~cpwAudgTf>O1dSd@4_1Y<#xJP$!>8<`!#xb<`J&maL74EW zf<=4B;R35h66iA@!Zd~1-_;Sg4U3`NjIWn^CMg zl#>SonVHK8sLAu<-K$54-G$fqW%8Y^{C*J+yd2E;>R%V!EM-^=cX7e^G;HKoiP^_r z_VK$4FnQxK9Btgd>>i)Tz70R|$faLw>9IsUe{3l}yQ+c9ryqxhj06V0`FWurRnT&B*S{77M43(ffZ<5T$8z7=S? z>M_bjTaZc3UD)(~2OdgqW7m{2(P3x`n0f6(JM&5WPya0Tt9A{#%14pFcp`GUufXYz zUQ9`wL{?V^gX*{)uvhIB)Je+2tqswv=I;r7_$HD3J}1U7y$GB?eIgHf?tSX`=48t+2tp1@=Gc$2U#0uyyQj7VL&&j`q8p2GpXCJ+3-f^CL9=; z#8)&fNA(hG(weVIvue$7$;gwquJIV@l`4cEp&lTT3?{Y-r@%RR5ZxloRxJZA7C&se z2_x=k(WW^GnEYJ~(gyZg z9VIpcWq6d8D=xOlWoM=h;D=(z2-k@F0_RK8D*j?3{RR{B#ImQ znH*K?1CNiPu;ab5*mQ3>{`-9#k6#q1`hHe;+E?%^*-XRPx0=K{$F+I&gYg)W*aBKY zrg*uB3iq9O2~C=&p|j6=n4_uy8ZY9p<0vn#{n#aT4lg6qhFU{aVjkRbG^Y)sX8SXp zDmbBb0{?jFC2QRtfc}@~k&8k;H_f2?~Gv2~oHxJ=j1EsKfjXF)UKFtr#7Un!7I*R|U zS&XYR47h6cTQ>BkG5nGJ%6j(9M^RiS)J%;*ZGSC1zibl!JV;6Ks2s!Eb3^&!wluV` z8^)*C+{WCJB zrC+?S_#CPV{5>n1SEw(UjHuSbj^%CP(~LBkhNCsnSLs66;5gWpBaB#9DAVf?)L5S0 zL~J)RA#Z(-z=tApa%RAK$UdARvT~+yr(1!u3UBe;erqrYkb+0?yy%sHTqz6{u=hGOLYE_1J`!g9FgPZWMKwJ1!b0aP{H`Wsp+eB$QQ+!X?3q zG|zDeoHESE0~tnK_pcm#yv7IHgxp_axANxPLzgg||~+@}eg!GxbOD{Y8s#(t>)NYAV6Y%Y=SNvkZpjZ2;ZUb4+au zirQ9o!9!sWW~ZHqeEvxmA8H7n#0n&?p_R4A+3~$E2GP%}vhc6G99GV_KyK@d;G>rY zqL1${*02K5G~+V6xH1fI`vge65kUw1SV;`_H-dpt9FcoxM)zF!fDPM>_>Mmh(B7-WLPm+kGK!b#M^Q^ zdCNyTE@k7*bDAgeZ@!6)j2psV)TpCgLN8u6KTOolIgx9V+;R1TIb3h>PYiCf;t!q7 z`4PJWd>XZhNdJ9?E2RhW@X4{*I=x!7>V_`HbPLEh#qDr22e|9>Av9g{C-9Iih@b31 z8#hS8*SlHZJ2C*00yHtk&L6ZkjU`KljKbs$C7PjLfIZJVacH>?AEN3Y9@|_6{w}_Z zNY&zo$-kLn(-8#Hdr0V@4tKEGYWaooCK7@eC*Ce}=2a`QCN%yu%E zI@iGU!#A*}qzO~CESS_VJ9w{SfEIIKkkk`5nU(ttGOuef+>AD5Hy&)FxAaz{{p>Q* ze$CGhNo z{MV#qviRSgH(>r^G0g5Z=CKppAU8`LLayi`*)xDwsQqP+@`uxZzuvIA(oA@yDfCZH z7*Xr{YrwxW1rDXxz>9++aH>~}+THz6wAb4ZT29IH@h2>4a*8vo-YG-N9*0o%jK8qY zc)Sqf4nn8k^;mmI_#dv;5_``9@G*QC-xFB?dSe&CmNV&s-m?Nd?E~5HTenD=%`a%Q z{SVhW&F0RlQc>o=JeI@9b5UIizkf9c9Y)52!5C9MD5nFfe=Q=HhD}C^@dlK9X~3hB zx8cH%FrGPZ2>0sj;v3)D)7yDXqE3^i{CUY>s;jTh;|<%usX`Lch7IK7{i1O2!x^Mj zZ9ZJOR6*_KkJF{0apb989w{&~p{hAYxxcW-A33ItXuOVs^EZa`VV7-+;x}sIG|e=8 zC36hN3HQ&(*H7_F-IB25jKHH^{Rw8=_9d~7eT4q)6dcVHglEMV(Oub4I@U9uKb*ix zi>n`hl`@wu_FTuaGi~^+s0q|_UoSa(@-EX7vda4!CSiDxG0hzP5p3HR(m68{aZ{2Z z)!wf_V-%Lric3v6PO}0`Bumk=CIR!i2l2xHP8PQs+VhiDiTtDYah_{X2XoV& zfWVr6*!oG#)yJB1ubdz%Rn-Z)9%}@CRUerz1=R7u7Pc5;$kPM=u|TU{$m<@4pXWPK z|BauyecnAZF*M}!{6B+Hs{uqFinqVeTq2(Tc{ixlG)e?uJEu_v{ ziT@+ZU`54I=y1`dPU(NySHah|McDo2Eq6sX(GGj*o|9Phb!73@rekQiBbP0%m+kpw(5m;;)M5aKR2`_~aZwZBGcEZl_S%9JmLj-bS#o3Lsf0oT!*-!Gd{X z`Teq3Uen==cTUREI~EaqmHsO>C8&;%$eF^GdxpZXQ%hmRj0ccvFHc9+Y@*`>2G9jF zC(sG{&Qw1#4ZlC02!6)5z<=f=OlzA+e&!vakwe2!ajgPB(H)Lk{Uvy_YB-obEJfKV zt1xM?30TEcil@t8=M52~`G)dqbajg!J(wBF>U$332I2E}?fwEf(Kq>(KYDbleliR( zHsjBdWckl!`S5maKQMuT6k@7I3+yELlAUR!&>{lM^DYp=-60@{8SeOh@TO#cG2)k0Ih+n69 zi}wbb(N)z#CL(z>b?CGvA(#44df*W*<>L>VtTOow>n+fidW8Nd{YO?>{6P8IWZd=f z2vv%F4M!@BsoYcvTK6}e4Br;UUw1u1E!A6i;{9pZY9zr^6`K-^~+lSE9!`*m0`7_+9j>mt- zH{fwV6wRJe0#o*{;8CB)LGPGA`n1^?VluLDfZ}#EJ8Q{x%`S4U>z|pmkT1#%RYt#> zlcIP_V_Y=gx#-TJK)U;)E2u~4LC4P1;C}BKfB5u-C}Q%Q-o)feDe({;2- zU|y^v$}F$lhRf=q`o1HJ5BqIG7+QbQ6aZyQMLgbZtL@Hic#ELZUp^n zY|Iz-KjxeEsiBI~1GpGH2)_mGrH{>*aEHR#bnD9dq`_c5d3k(1H@X>ycRmZ*kDzX9 zk$wx#su_W&+BX<#_K^Oqt0sHiyYLGO6nNo`lQ=M89Tv~@LHf{JV8!gCLzizLo8!F% zW=TI5O!49MTMB5c{!IEyI+!lG7(hGK{OIe6b67;UCEu5Po+|9z#@{DB6}N0J#D#rM z{Ma~eo_u8)O}`%oeG=oScu^o6Aj$t&RJ!fcj0G?eRYI)$rW`|*zn$1!o~VJiQ2AJ@KmiT_Ri2IGqg z$Z&zzch)Nj&rNX#z3@~lzmotP^TPSr0m^iK>=Rr4fU_7NZH*p7%Qi))by2SJF3e-v)GmS zjZMcPVk;_D7Cx>h%a;uYpC?XK zG~+$A|FoqpGis5gZezJ?2a8{DJ#eDIxb#yl(N&TrPDNUHGmY?1%2(OpVORL9;>QrV z{}&0nm&%n)?&A~xN@6wpC=|C1Armblgz~zogPcf}y0@;UmF4A7Ir@iqdO;AK|EnAl zZib@dJAng}9Ki?L<}fxc3TvjH!Z~X;P#f<7Y|5|^^iS7KSe3MfPcX@&4_+7Gr_r9& zWz#!oEl^}{qHn^GIdk}9!y9a2U>LXRUJOI(V?d&{g>MpOIQGf!(ErSFdbngGDhoWa z)8DJa_YWO{HSaZ{RdWzMAoCW7-hapz#2=@|GGjqs>i0%5o+|A;Tcxws!5|&y)blRI-6pp%!cOgEAHQ|f=*FOX+f{x*X|if-Im3n z#*0Vi0iakoXEamTw$=&8Gi z=J5|$e*7#vY?I*zxh}NTY%i?1mdpw&uaeRtdHihfNt7`7Bm6En23I3q>M?&6ZB5-p_qQe>T`?G*6=s5%gb|&r6F_|u@YQ!2o+W#jwJ!3Y9w#fY zR<{_eZAU_sLl{%)3FSxBe=`g!!w_Rre&LKImpU%YdopBbYi1_Q>YPd4-)2*J+j@A| zSx(J@uhGgjpqj$DvQ;9RSALbF7wshJts&JAwO80Tf0d^TZW_U>Wi{})@)gmv*XC6Z zq>PRiU$vALgBIbw3Vc#unb@gnFYh&YiKkSK@i(!p5bBUi&(#=#`qL%kOL83gnU?|y zuwxxNbI57^%~VS5Jlua9j!$N1*vap|46`0clK5_Y{;>A0NV(4dxpPZ#jd2Z`Eh!Omzy1!3QtJq7T$-9?hbVM?L_+tQlV3!YP z6^bC<=md25pU0b3W2k+i7MGoQMKtVl8e2c@J1I?( zty&8a8w^Dq+5VuABzU*dwE5)34cMNs4dyJ|3G1928orAt|q$Y#bXx(M_1otVfa6c$!FKN_eBpfyHZ#_ z+hz0N&mW^Xnu=sQzlfgoC_>1*4$=2Q1^PSmA^Y=NhUTqW!W`Ys!&!+$@R=qNX{X z#y^=rWjjyPCEAp}c2%bz|DAJq0o@KCMyAju{fe~tca^{-sG|lOqv@B5Wf(tgltbbC zFna3bJ23CKjXG-69b7}X!#?j&2dC%H`F$B{2m2j*x2=b3Irvr^(&P&V>BvN9dN^|i zH)t4kdu7EB!cGX+Z|`haA>#wp+IsMA=^mn#7Km0pMc6+;l39OxhL?wY#O{?-pmJpu z$lF#xqT)CE2-^sl6_Nze0mHfR=i~4)cL8MQj>bn{I$6GOR<^mB4TC@SiOueu!=j;% zP-7ikY#)`u*hv>$s#AsO0U{n`XDpuJrpSK}*5MbHo<*0kk2t$Qop(4)2f4aq5Tt*B zC=T_4y4A_-ue;!ay><_So_I6y?f+2L@v}HUMw#RUd4g|+JEqF~BO*N)XqS0{s|0`N z^Dq@sb-op5J#r#pukW%YBR&+`u8W65AHTw8m+!d8zrwzZEvF8ViDZE56;Vvz9}@C6 z0v?7rVD5!7!7sE1r2LMv4O0rS;P5qE8y?GEPXK$FQ%h9a})9zndo#S3thV{ z@yjWo9=Z*JBgLB}e!m5YaW9dYT*grib4cX&0?_!FgOQ^m*$$r%aC~tEhz-g?X{#<~ z-;ct!t^Mr525Yi>p$s>zJp|_K`$VR*YlLisHO!j*i-kxJLi_KJaQ{#fSfsL^P2Z)4 z2TfNJ--a;|IbaDd%p3`^m)}E4c^L0JBjUeXg&)Kx_F9&gMpFvV9wuY77=Fa8uVG0j^e@OEZ6i!;)~ncg*^`tA%&$h?Ek zvrgi!*j})GFF}sucx;XTfetFK@#+^(zN~ry?=beq>xtKK%H<}E`Rk0HQ`<<4FgM$} zJ{DEAtMFr~RPpzz?U+(sg5!JM;md&+;rb(iCfjfqyiV^#v-6MP-IlwB>8vHXR6=WTq4TWqX}<$e|1lrRc+>A0g@hbGP|?i5wI z4x;*#?vcT%Rc!s~60FS7r{TwMlM%{)*c7!(;t=Uz6jq~DbqsO52YRk0$aunuJ1~RlhP7&)>I|x>N_4&G9MB78Dr_TIc0b& zYbOgGy%Aqua=}}gHqfz93}EkqnSVprrA#y0D_+W9dJFlJA}PN5fG$_h+{q_SkK?VY zLSVpqSFE4Afw$J~XY<->aO|eh#Mm!{_$^O|$L)b|qXCI=rkfs-kPrvBX=>8Zc_4hSM4%dRZuxsS3k0#3fj0WHS0ko&`ECzY2 z)5CAi!%eePCb#-C8>PCRh2Buc0=qJ{&Gwk+>%gIW)%mZa&OeEnxwR3)p?Y7mY#LZhnw{ofNd{P!rc^(WL1ey_LRfp{i!fx>R4FqcN6p!tY}`F zx4>>T2YWkVUp&N;T-kev7;k-rdZ+!xTb|rz4joCXqPdM-m5GI|Cr04HuNgRB&l-<@ zZzEw@kuXBmg-tzh5{v{UarvnbND_~SVu4XS@xPbsztMZZP4H++{X2=GmJft>xrwG% zp8@s0$@pno8Y!}y1Un~7!xFVEuqr=4Bz9AjdraOa{(dE5ZJJ;DWbM)?w*Aa}Iy{lv<22BFf(aQtVkfah;b#b`+f(aF>K*j9QG zCNa^%f(*sQFC_*J^ecbNOy9I=)bHKpr4Mi znfISTahepqyO{?qrzawm9Ao~I6-1$LZeZvt19t4Gn9cn#5G5P)*oWdlymYr3ll?Om zW?dQ&+S@OnU*!?>@?b=9*G;^$>ofkbFCsp30&#@EkesZNgcA=Mq5Z2{*eoy)RZe+8 zdZ0BN+4zq2pNeJo?%rqq`Hd)YZ!LaGh(}}p7;q_)Rv_R*d`x@QQk|@Y4ma?zg6H5 zMmwTH)IfX_CkHErWnq5(Wwc$$*{MTki8wMHkKqnddF=vNlwM=RdM#Lk?@n z)dQK_0@#qLguM&yfXe}GN`#(crtfwb+aF3cW&<01txR;QumML{yv69cRQzaKz|3o% zpmK;WXdFLEN*+tmmqvr>rA3d(jWD7A7_=VV2#ik|#Vpn$lgtde)9gZ3-LR%lj!SH8 zFV5Ngj%CfV;%_$z&f&k|Fut}Lj#{;Y>9Ka=?m7hv4DMp!%Oo^TE@zqRm!Nxf01VT+ z4Qi)_2*Ff)So`fYOdi(2$`_Qu;4wRxrdhF2BQk)2iU)D!q50tT{sxTdKEaCI<3$=< z<`b{6jqI^ljEB2JNcEC-Rx{@--dJraetzT$diu)nN6#d&FGG*-)+j;Wnn(zUKL$=W z&xi+~oD7eyWa5o~pU}@P2|E0m(fOw)i~N&UDDPMX=O!M7&ip3UUaXC>rDjl?mcml^ zpJm5SFcA@6X#^RJlEgrY@AI@47 z&UDIk>DhagFmwKAXxb=AI}8ia(smH8-W<<#JBQ*!Pbv8E=>wZFSI9>nmZqZk`_Som zMPzC!&EG~9!sLM&Z0(1sxJ$?aZqE2j{;i85qrT^g4_%BUso6D5@5LPwxhx##=Uv9C z<6E&%ejLp2KLAt4+VP;Twjg$Nr72l6`Qdee#Hl%!ED?4kZv@8Ns~#;FF8`a{`CtP- zL;A@YgKMDnW*%Jh75M+A4Pp``OWZWF!O-m~mivowcu+9DRZqml`R+_R?SyE(_XKpk zH5b{gmCSpvI_S=vB>sJUh4}hx;rppInONH1M%9c+WUP>Ql`^cHa}qaAGXS48N>obX z4SN*2nQSzO6WNB;GTZzhtQocs{e*6zpOq<>40YxjE4Hz*2le^R^Er>d3Mf%;2~S!?f6MJvH25@{h9#10}c2WC$Xsixg-gqp}2APGu-UH zhpB#>0Ubx)LCeQdoRZiBUFW@-8A&4-L`CrW^KAHNa~7n#?~AV&n8UW$6%f^|!kn(D zGlwv349RN8Sv}X_Oil+G>z>bi$NeGGE`DVD`m=ER&!5b?umNTt91rZdDGsWsW!{b- z$(_aKaRNPINH|Pw^IriJ$)?I>{l>Y|h{k_EEYA=M_n&7Yk zDY1rr5V|OZv$+?qit9(rg#!j*ptQN%UR{rY*5f(&X^koGbL_=UD}C8M+v{R8S95-U z{t&*J4QFqQhVaU{8u(vZBHDDD;IWpo*kgGYM!XwMCaszZOAOWMjMxGaw4|KvDbQu& zjn=Sd?WEIr@8Lv3rYKA;Lie4y!K_W+&&+ zc3T?cvfEJBTMUDL&7%)7mu@JLAR7<<$6oI9z-c3e^G;|P8c$QY^)^aEUV#c*qGEAEeAc-!ijy~TkZ^yyRJ7mKV# zCCM>(DE}ELHF`@9t*jL7JzO9@e?*rQC{&8x4X^|asmmlQ)fv9+{Et)&AI+uaX`pgh z7Igj!=la_xiqAiu#2+QsuRyrtkv;$awTHJSxI%M&@fzF?>2zTkxM3}!_h!S0wm zoN_b@gMF&;Xx&BL&~Aj|F6r{Iex{JIx0a>9SV4@Mra`&WKsaz;pVxKOicJN+^enlL zxW!SC|2-&%zyZzRX89ZP1M{Hb?nqiB?5EBQQG=Ef(a^L=o)!$17RBXN!Kbn3VU*Tx zcA;wl(=>j{ro_gHa#bSv@n!C4UO56+-Rs2HGa@i|zXt})ZY4e=a$sulIqtT6BHLZ6 zEM)#Cg6fo1Xgae3n*7FLYvyUVtUih?b2SG+^NH&&%*M^p73i&b5*4n8fbZla(O~Jd zuq|LDv|4D3ii`?C>+%Iq_L>QOeX3}Edjk8?DV!PNZ?G9Q-lWZ66OSfufzigd*l*K3 z==YbT?(Y+zGP@A+{11SJq&;(a=qu{hhj2-iOtj{l9x!F*2(3@}&(p0?{? zxREEGIPnRiua4x7a|`&Odvo}~x>l47Qi2=*g896f6&&Y25dUmgg~KbxVeZJV?DO8pg|1|lpljqjX3Ig)RSh-UM{#;eiDU*8$@z`EGF)dpc+(CPZjlB6@ zPTIj7zh#vR46FuriFdHe8xlCt9>tFc*C&aLg`z;RoqhTohQFO$FdJ84X_O+iXlU^- zlNWP~DphPX&I3bv70|gIM{e)E1`iZ}3QT-$dU){ws-W8-nlofEev3Jd>Q8Ut9^J)) z`@4v_Oe+v_qfgoXHRlDF#&_0{GXax3s_}06A?{Oq0aq9eMAHTKe4uf(y~WNt{1hV3 z2iz+lV(q0+?0AFeM8v~^=^qMqr8Mxv_0~Q|*fxpKQl)`TcX9Hg- zd(ui?3`oa!1K*K8hjNxQ{DXM9Rs}3P9fQG&H=x#0lB!>^qKZLB1oodhozeJ!E$kT z$v2prrCahJ1?{M*oWQDuY}(jjQ>yhtk!Q?^Mop~(yp;7#$QE6}bqaBKpm&V$cUdrG zOAq|&iGh`GT_ND;3moHp861Y;^5Eo9?AvS& z*N3l#qI^qOct!=5y>A2QhnL}e{94#`Edwu(*+}Zg3p|hCB{*Y8BR+Y%7t+SP!X5T? zI3zs_>Oukq9>_DiI&pR-^pbcme6UvL~oF3|$L6~Dn>xV|p3`pV`>3?&JI^XB%^I25;PbFFum(cy{U zV_z76xgPzvZQ^Jy6RU}7rstV+nE^i}KlV@rz3 z_@!gTZ}tdo_!4z6miFe7)05Efi7w4t^bGZDMl(~XAZS=~05AXE3a6rX3C~YQ;JoH3 z+}-h%*k3;f!2^Tge#}hlTi4I3uLXd{XayQ-dReso@pHDmLgUVM3@Ca;UyGs-*# zxACu=WNcU2trOzF#7c;Ki+UZ}W}SCanlHn~8&eQFu@yg0$ef38Q3a7|u* zSdWuUR^riS!{AlHA6Rw#JTv$n05%s~$v@3-vg2VL&bVUBU8YS#oqwfl)Z^=*aL*ZH z{!^vckPqh!Qz0%s4-{6J!G8-3p~mu%?R^r_Bj3n413|eiByEb`|>tzDhQ6U`vPB0~n!6Bq&h|rx2 ztR_a16ddid#HKd`*y(S{aCV|23l+_z4QH0qG4o;s-mN@^i6gk}c6Epp7_u*W?n95p z43fVz7&CpQ@S;Dj@ao!Tc2@oabl(f%6+&Ogamzd$C-5v^&(1=5;}mpKJ&&xiS*)j` zL9Zl4;I39hDpq6Yu~ZN4kIljhyAO%KUII47a{$VHA4MG=t|PX`W1%^B58L-AdGKJdwLleKlYE|BI&1>Dy>H2j$Dug7=LxBEs=|}2 zb(nV0N2YOpn81`x!C%|-;XuC>bqpOsjpWaQjirh(5-fwa!v8oos}7WnED3JTh3_r% z(QhpyvDttH%I|QM;xc$~*&7G7{=r#|ia1AI2Oh_d#&Ee;m|(t^$qlL@)zU}taCDl$ zoSR6?&%Hv}9!H{|jD@X%UCcu23hZ(;fQ?u4MMvKJf-7?GSe;=t4iNkgvs~ImF*;+o zWo0_beN2Va!NKCM0fEq+R7i-Bk6bqJe}>M(pUO83<78)+WQAl4AqnTXUzAYM(x9j) z`Dtq>l|3@EMn8A)-T`)SEa1C^w$G?hww{oa4ze9rlN-uFEBb6?l>y_6vS`%UuB z={p%$ArI|Po~OnT+HZLO}H)BY#IQ>X2? ziUtRcOZkX<^s=dVSb?zRY8~F&vI=~)yFkb-K$C4Pu%z3Xo3})sy-l8t=T(yhkIwL! z_DDTu*Q3lnPU1B@sbg4_+liy5Bld@s!sX^m@N?oqI(@r~V8PjH(mG-?cf52aLYVnxSU5S@A!)!x?N+A+$o`*I37sL)N5pDGBo1eH*m zzn5T83a&J5g@<^T(m4xopu7~VjLg`TGHq6U?Ktck^&Vss`JTCnVqEF0N=TsNxNSo* zVD`jEDRodwOfA5^Wbn!YK$L~`DwG2$|x zWh*?SC*N;nM;$y^=IkN-Q6bJG4ob02vO4fM@hVk$(h15_b09;pA76W^v5CV6pj(}1 zp8T<4*6IRixuXqxFW-Y__cy@IGm=p4YXyC!6HwxhDV11v29D*9;Y`jxqGu9RS(Ujr zJm&9=^O|)yt+&N^r)wEE$TR-R;#9b%3!}I$+tKjA@-x(57G-N~R@?b&P3GE^SogFIh$+(eFbf3(8;ca*sM`+_-$2!O0Dd!f9; zgiZe7gUxa?Ip0um>MhGC?Q#{Y*U0DDF!L~QUpBmbSxdCuex>QV6u75hdJLC4F~QX= zIMMush~Jigf2rezP73)TntO;;yyWkZVe3IOsg_vGuE%OuMOySNla3CEV!Fa9kZvkO z$HX%ORMQ5?Ix&vQ3chJYx&>K>s*dr(D%R|f;lHh{uD}Oxd?K2 ziy@af;vVund=Q>x$XSiHU}tV-q4`_Foh?_z9ZLe4OxFhHSnUF~*>9-H(k!eV70nfI zyp7uwe`0Z09(wzU(ydxzY%JdcbxWZfP92eEW>W&#RE&k?BUQMc>fv;=>T4{_cV|N5 z=cr)o#llv|Gx6w6c+pIQ)df1Sv(++?`isvos@z9k7KSQo%kXaR3+!$4V2f5A#hFZs zJ)iv_+-`bG0>)}^hbk_@k!OEkU2Z4bn{)#r?>EAJvCGhR@*Zp+HEc{z`ZW@yBg(cj1=O;T<#Zg%Xehr!X7ye~ zs!a)6;`c+Z{VDiq@DSX+QlaUGk^mn0!9J}@;U-a8;ke`HF+@k4+iZLRTpN>LP{TK6S!>+p59k-!7=THkVVV(&bj?)eDK{3JiasK)xPJ#fzHyG<0Y))!KN9 zpVQi*WQIFub$l_al2K(3A|=@ZrBj&y{wh1M)tlAp^DIb%LN?kjfw`!@!e{H|u?M?F zAp2n`>^ePas7hEk)r-&1|L=l_)oj9q2$`ooL%Q?nOws#6+CMYkBrsXKG6 z-Cb~|qmS;?N7!*VKww>ygZbaiLcMVoc+3;!YC7u42d#9tVmF%GcrqIY)UF7ER?V~b zeZCF*63?Jc=ysOK^95dJT_)GQMB)09Xxud+9(ORl<7oD1?wU_Dj0!tPw(FR2Pm5l` z*UAi#FA6^IJYg~XD}7C4?aqO%vJ=IRr~j^~u(6MX#j5-i*On3kl~qpo`kJuO&{8~*8`h0#d%&dwbc zzO&;Q(dHns=eS_`bV=s1vKQA`NHMEK!lVu3*kj=uG%?l22Q4MIjb}anyBI~|_K&3> z*LV>%`DIYEZoZ(a{3`D;yo}2i$urOTTriJo2AfySV4-5ndB#*gxgZYK>o3Jq8xK>b zajlJTR*VUDrGVJgPH5wK@2Uy6AT?$rSG!{)u4y+Ch&s-KbKmBWn?<*1fL;}y7xo)( zX8aI5h`CN?JW#@gz0Ks2<`lZ`kQ;9Lo=l(2jmGlvVJLfQDxCXp6ierSq2(%e_*nJ? z?)B@XO`qlxgJDOU@w8BI)STeAU1!jHN*R^W^g-GC_SiVEN!T3V4clUPu=vgb@=tOC zeyt3{yeZSr^8G=`@yZq!?43t^GLpbXRu0~*HfN7ZOdJ%`p1>ms3&?vh18p{!LH3r5 zbm8$xdgGu8E}6FnVBZQs13u6-?hbvpk3v|Iw6ID+9nIVKW5vZ?q*~Y`%>9xEQ8_uNuB8aQo2wwJ zZax*Zq`-nh9^n7zBL3TPm>ML^#tF_{jVD{;u=?&GYUiJWWGP!VcJXiA)*LIW*M9?> z-0ost%Bddn#98RTM>rLU8tPi*r@x6&7!|DF5qsX1GBRIr0;?3Re z;G{htjW>>^RoOcsV##fu%}_;czoyYCG4YsUBgIxZp2R&z7PC?9;V9R(o5&w+Kv$Ix zJjtIQBa+UM(AgTuz9*A)IT<8aMUNh8lz<&8j+20?YtXHXf!#h?&Rt2Kxtce^F&~6K zsh`PUp#-$wlORW(E~2~x@0a&fV8^me*h#(bAp7K2luiGcE-J%*?Ui%SfSRMn>e!JnW;WNG$MUt$4M#xEcX6ky8ax)@tlE zpFKJlAw})pt!86A4VhYsXVe6N)jJAS+_E zM3p^VWy~H_XOdC6&3q^CQF!V%k9w3T;S8gN;Mg@lKl(2gY7f~2t&qr>J-9zl1PK870EV0JkAHkMX5()Z^L+5YP~Z1c>MIPtVLxAeOv*C{T?`8?@^ z*~)z=ZSe+|7)WyNSIcnI-X-`}kitX{zb{){tc?yIHQ zcHw$BF!P(>y~aCos&$Y=j+qV97DNhzveLj}lL(DpxCulW^69%qRapE_0GlSxXM0xy z&Ze!f;&?t2Z&^$prrEK4pXbqa8mMzG8bV8Zu#WSfaPM2OvnX$8CoVpJy8edtc& zU5vn{yBGxhe9!rjZNx%22=)`l{iA9jYKRi?X2a+B27~PMyE!I%K;|xX_CPU+PIX1^_ z469R-VQr!I4O zpwBi|Yq7JI&LEmK2wMvNfuh28kSMBvz+qkP>GEA*|Ku{L9Z!MdpAN#c3Gv|5{}Fcj zmO*m*MiSV64t*A`5cr3gu(Se4*1AR<4l6Fl*XoN=?Q1-enjYf9oq_JkleF1wJa_Yt zGBXIU5vmxE1^H*2AvL#?bf4NUXt-Gp5B^XHy&&Mv&YuEqwk-R?Ggaeb?YZHTCj|D2 z^WjeVJ`#{;fyEO~lW_~8pjgx#VjVYuq2Fz4b3_!T1cnOcaNR{@~nVk zZUxw$C&uREDZU4D9X#x?fN~iR&iLM1?xC|G=Q>%35W*e_HNEcyY z;dc^rqZTbd9fSU+(7v#Zke0h1+b->8a}&zYd3+L|QAmXT^~q4VR19tgr^3u=2`*^s zDd>4JL`F!uVCab?vdDnv#^mKem%}MBMV|)XOK7=fyQ3r*rfI> zOwX>s@0Aj$DM-gfcl`M0`!-FuoFec&y{+-!pfZ2UbAu<+YXsv*Z^y!9bNI<$x5?>r zUQ;%M&~HPuFyssUBfp>2W|~u}ZM^Uf&Z@%EY(|1B<{&U ze*09ui!Yg4+S$_K+qpt1)`t~ZCrD8&|9<}62JUlwu=eE};#Q`~$*8#ChgHM)y5yzs za{fnpIdlcv<2#9duKk1RiUa6*`=Q`S*$|rjx`yQJ22AU?jF>yOQ5a z6Dyaa`?#496F-ae$4?`lXF0Rd99MQ?#1Tk9b#BLpP2gBDntvzEVUuDR2uC<#=LSFg zxb8Pymd&A8OdlQDKNm7%ZxdygQo2yQw&Bl{OBiNv&hszdQXfxWoH~m?$3+vV<$(+| zjfxe#XZqx|83$h4`M6?G3ialG2f0Nb@Lkto4A!cKJl|0?s3#m#2j7qze2zPCF5*>X z8#3{iD64O8#tp9(F>~i=CWx0{Izibmqg{q2TaI9!V_RV6!C68#jq}*Q;|h7RMhoYT zp6qb>*>|WFxgb=Tuz(aVAW-IZ7V=bv1vidl(NUujni8cT<6{Im83j|P)pm5c^c{RD zCdaV336m5I@YU>A^qqGHtzSu?VcT<@=uwNyE1uJ+kQc)JU%T+gv|o)HH6zHbiVH-; zs1Za|G|=JJR6NnKf$R`ikby@guzXp&F!IYb`pb{kFgp1E_gjZ?`vDn2$btY6^^~Ob zN6NAE%Qi&oD2$pt4~d;E+MOyS`sJU{StS@>b7{C>&n{XXX-Q}7>88fD3Gk(W_xD~I z$sB7NaKt@%Ft>DL+rBztBd_OIx)0#MuOE0~{d)3r0{JiJ?ZZgh@J`M91o?viBbiws#*>wj2d!}mKcXv1~$ zMDd=L4(je@v|*Gc7B!CKnK93?&Iiz`U>@GSHvkVFG*Fw6)i7D{H#~fI z2~^jdfZ~U(aA9l&99X46RzAH-cCR>u^S^u{=VOBK@Xt(qxw958cGTgiZ%^?Jw-p~> z^~4B87mT{N6WU_e!mKZ)&~h@K2zLI1gM+IfXHPM#ixb7r_p|73`JG@Dybg-i-hs2< z!l7qChTC#eof|GLhv%kSz@}&?-KcyTr)itw+NbeokvRpvH|b)a>|9J-GaJ>c-9bJx zfp(}mlApg{P@`H)vdBydqk;is!_GJqw#*<|$9R^nOe4Mb>MkwotrzBvZfR_NoCacQ z>dc@r5kx*%L7P|}^}etR_I%aHyM30zPO%$sqr-~(Zkr0-8J9ufmMK?60x*5_VImhF zj`~yHkvB(s@n}&J7Vj^F&E3r?`so^OFz>Y=>l%oj|NY0#FK-fbgy^y2o<;0S=r#hQ zlUVY>IJW0`D%EjG#DC*+Fly0yTx! zp?ub+`lB9R87ih51va=Zxs_^$SRh${k%mrdq344y(dBbB07uTl%&#GWp8@A7r!+`* z>4`F?t^y-XWx=JiRN(QiUMT(WF6q5%ha0WeLE7AfxURL3j zrI(3&{R~{S-h$UZ%V9@MgfLTG9KKvWBkUcs8}ELzA^Ms72+|O^D;G}lf=lT8b{DGb z`GU&dn#z3qDRCOR2O?#c2_7ClO5d7tQ1s(JjOp{Ers;pl2e|@hEa4gP#jkKuCZSrJ zdvVOPOGxbN;jCUAc~|X<=2s+O*1tJ0N%e(LVN?vLUj0VqEb=F8vM8NuwiG-9U3m{} zI38d1mO7*#rh}2C5O!`3tY0}q{EV|u9)~kx+J&>_ zwb=dCK^jx_6cruQan8?JRNAG_Hem3jo0PK$8dgg$)KQzgiiy@c5!b9i5|fWDqD z#Z=B!U}@et``cfy8}`54yZip=YzO|3n}j86}Hp6Hf`3C`6*+H4~iFT}MX-9wc1z6&RV3CD1lC z#Pk`#cyr4j(fT%pt#rPEa_?Imj!uX|t8?vwhUINI-hL#VJz;7?T-h*QPq|3?Lq5@= z>e2L~o;Ud1T>y>&Hh6GSC@jny$qoDyVaiE8AZjVjy~<{wbJzmvmc-*S^9|&H=vd}i z@LF(6Ta*P?cA|COWrsBZbD_y~227Z+1(vi|gVzxSZb0rQc&r}Jolwx?{B<6I^%E^v zx!V!nX1S9H=~6sFqf!553${0%Cmo}1;^|5gc5d}ce4#Uk*l0%3<~S=sS?ztuEjNSN z^cWP}dJJ!W%!Yp&GjMJ%qcdJ#p>EgWL0wu1HTOC}CE0?TlQo)KQ+*M1>m_M;Pq;85 zX*=$<6N88iQq-f>4VP^gH;w@PMeNGB6z&MgJg6Ql$!q9%6nL+ z6Q(V~I=>$$>2Af1?}l!W_-Cz+XYDK+Pk+n@IUPk>(Cvzjb${va2JZZUe|*DiR`eG~_hQ?dO;H%3{;qDrtj3x0f6ptesDZcki*YNvxS%3~E# zo*n|6^CmQURzSzxctL77EjX{2N*&JGV(!la*#7AYnCz0^-furZ_JtM`)yhop?2Dtu ztD=ZU*dl5(WB?xSviMoT#KE86fz~AM#lMb4Sp4lP9QHKCQ{5$)F*pTfPJR%UNl2q? zaw3i2`VOb+hGHsa;qJIR`unpfh}3>0(nX@M^oBp$?&4WB2YJ?qEyD}OBA|F^JRK;_ z#QQ!=g&{QzH<$ZEtEx0}9+*k`YA4b?vNdqHHURMbBU$;WPPGK0)3XD@ zMCDI#=Ab`$f6ZE8ZF-7y@t!!1pn-Eowzg5bm^GNVs~$x?pAp;KK{|HNeF&1+OdmzN z!giOx5b}$I#J6#}RfaaFb8aeESE|HK7mC37fm%FcV@u?3CV-=K8_uoIhT9?HoO97tQaR_SAVWKv z?wD)Mu8pu}J^YSraVW6bohw;D&=5+_kzppjCMN;mCi_$&;faxQsRr z(7rd0r5O8x*49CYe%Hw}1;c3X(LioqW(#-9cpPWd@*VQ2kSc5$gd6F%@rBJojfs zB0jqPil2RkVBf8~eE)VL4c;^wygilSO{fk$dN~<97o8Rw*-vP6x-c8PdM*gN2F}2P zsWaI!xgLS-P%#O4n8o_9XW|A>;P%IL3(AKEh=q6@F>Gvrec@>^-=~1QILQ0g)ek|_ z+08sF@di1+Uy8F*dJS^-U9n-79lRGa<C(UZ(cxV*of12U zKV*R|mUkdCZkluY$+<*4Y84u01;Lb`ZoJRX57zY^fxE_OfGQj>MCU8i2s=S$b*jC?%nEqbD8qU0_9Z?as)ZN$ zdkmQvh1;TgNx`Hh68~Wi4kS1T2fRmfxfPvAH%qa8UoF-a(uOu($C&$bXBH5kkB)0H z*7qr?-8cc#52B&=%UQVD)=bKVQXrt? z6U?aOy&rSLaOH#9aAKS*zKAZsi-H|&-i=nGwO|qY|Nc!+PYIwW%6Ed|v3PPmp`B-> zXmI-3;gF$c$Hf;&a^J4B!>2kUFm2L@1;^b177HEvpwIt2 z^J_=pzxfi}isv!(ee50BJKuwwu>J+a9@OW~{nh4HnG6UYFEk)dXM@rB=Ky)iGu{^J z4AA5bBktX%9e73b9`?CzfWT$LIC9oL)=*IbwLOEV^IL|sj8VghBh-23GT+^_VH+lO zJfyR9mr%3Im&xWoj|3he2{3&i9$!HbNwr^1vU#3>(FZZ6`jGD-n*JUX<#afSzZ1EP z-Unbg4BUmmYY_h;8HYa?k>EWe8l%1*!x_ySny<6uRvxg#zsmV=>Z>ug>s&QConQ=h zhKexCbsb9A`9a^`1+;sg2;?n|#VdD2An;2C3G`ovONtyI>!ut8)SncV$;Hx`-HK>F z;~8c?J5FXL@Yz%wGd>sEj_06--W&GB>irBCDMjH9>kJhAJ_To|Il`7dyD`Po1TVLb z;`2A{^jxbd1dd&WN0i!08L#}w&6eZ>qpZN>kpoD6h{36buHfKG=nSWB?7X`giqd}4 ze+zez-Dh$@*DXhQcgb0=DU;a zaIVu>PFlec9^ZOHB)%VkwM(jK`?p$r+#E_@o?ZZ_#atnB)^t*F_5?XlEybyBlBS8j z{zHi!jif7RHo}E?2(Owbbjf}~BV{E~vp5-?1Fe9$@XxqiH|{8_L-A=x1+!KaV2QUU zMoJs9eY~%n?tPD8>O0R*y1jyp97^g{!ssJKa4*f6?`g{ct=%G=zBI!9u^hClvZY_8c>N)J3sU3d#Ag2ils=t{KUPGM z7nkek;99=uVZax5%$4M1x8%aZk+O9tqfcO=1;Xh(Gq{x;%I$ll${pkVpBsuA zsNtbggf*OmUw1CTnmQRSZ@n`2cQ(fvo37->?vv!K%Je{&*PwN$nPOY$N0I=OP)(Tb7-`z#qD(v>}P-w3>pe_ngML?n>BY9Y-{lB?zYdJO+n@`LlnTFRhHOhBH+m zygxXTTqv`qy0#Z!M`A1N|0TtKPYQ>9y5r%q$5NtJAx?Z2roxx^pGeR=zIU!pmfJPG zhZOXe!?m|soZI*!~#Yq;HN3#`AmPPM&Xhi-Y~Dk)XN7 zh1bj;!AUj1jtn%1>I+4gtPxnvGEPoD3#TW!ic*zgiurn(7RYHV;-izbwO z*@_Aik_C1Scfig#kKd1oal2d6q5Sk$S~fMxVX;O71d)-re&1yhmG1>QahFL%$#Y`e zW=f8^N%EO(6)f|2N4M-i%m}T)T=7%H=Z!XgYf8heh-btu$QWF&Nr8FEHTw2pCb_x5 z2<@Jz;-W*+wApJTF36O?34(_NdrHZxat+j)z~{-$?D4lj4k^0$595Xkac+(o&-q

9KI%k#K3C3JQi)P;bFyGE~zD+cjg+?aL)ne3xL# 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 literal 369540 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 z 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⩔cVXBy;i2 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

}M!yXpSP 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%0DZz-)}c_ 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�CErM!{LH2}Gx61nXPciO~+b$m{n? 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 v = 200 - y * 120 / 400, + 6 => { + if (170..182).contains(&y) { + v = 40; + } + } _ => {} } img[(y as usize) * W + x as usize] = (v + noise).clamp(0, 255) as u8; @@ -310,14 +317,15 @@ mod tests { img } - /// The numpy reference's output (f64, the same weights) for synth_frame(0..5): the pooled crop's sum and the logit. - const EXPECTED: [(u32, f32); 6] = [ - (150775, -1.963_032_2), // P(open) 0.123 - (141425, 5.974_573_4), // 0.997 - (140819, -0.387_576_46), // 0.404 - (130502, 17.097_528), // 1.000 - (171267, 16.751_217), // 1.000 - (143985, 7.796_651_3), // 0.9996 + /// The numpy reference's output (f64, the same weights) for synth_frame(0..6): the pooled crop's sum and the logit. + const EXPECTED: [(u32, f32); 7] = [ + (150775, 3.160_760_3), // P(open) 0.959 + (141425, 9.458_827), // 0.9999 + (140819, 5.254_760_3), // 0.995 + (130502, 12.346_606), // 1.000 + (171267, 17.556_217), // 1.000 + (143985, 3.152_407_6), // 0.959 + (146686, -14.092_843), // 0.000 ]; #[test] @@ -347,8 +355,8 @@ mod tests { assert!(!s.ran && !s.on && s.p_open.is_nan()); let s = v.step(&img, f64::NAN, false); assert!(!s.ran); - // Gated (low or closed): it runs. Frame 0 reads closed to the CNN (P 0.12), frame 3 open (P 1.0). - let s = v.step(&img, 0.3, false); + // Gated (low or closed): it runs. Frame 6 (a dark lid line) reads closed to the CNN (P 0.000), frame 3 open (P 1.0). + let s = v.step(&synth_frame(6), 0.3, false); assert!(s.ran && !s.on && s.p_open < 0.2); let open = synth_frame(3); let on: Vec = (0..K_OPEN).map(|_| v.step(&open, 0.3, false).on).collect(); From a08d0fb0867a798c90172970eafd7d434c692041 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Mon, 26 Oct 2026 10:15:35 +0900 Subject: [PATCH 53/65] =?UTF-8?q?eyecam:=20=E5=A4=A7=E3=81=8D=E3=81=8F?= =?UTF-8?q?=E8=A6=8B=E9=96=8B=E3=81=84=E3=81=9F=E7=9B=AE=E3=81=A7=E4=B8=8A?= =?UTF-8?q?=E3=81=BE=E3=81=B6=E3=81=9F=E3=81=AE=E7=B7=9A=E3=81=8C=E7=9E=B3?= =?UTF-8?q?=E3=81=AE=E4=B8=8A=E3=81=AE=E7=B8=81=E3=81=AB=E4=B9=97=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E3=81=A8=E3=81=8D=E3=82=82=E3=80=81=E7=9E=B3=E3=81=AE?= =?UTF-8?q?=E4=B8=8A=E3=81=A7=E6=8E=A2=E3=81=97=E7=9B=B4=E3=81=99=EF=BC=88?= =?UTF-8?q?ABOVE=5FPUPIL=5FB=200.5=20->=201.2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 度付きインサートを使っている人の録画 2 本(2026-10-25): 見開きの段で半目と読まれたフレームでは、最初の線が 瞳の中心から短半径 b の 1.05〜1.21 倍(中央値 1.13 b)にあり、ぼけた瞳の上の縁を拾っていた。0.5 b では 探し直しが一度も走らなかった - 探し直しの境目を 1.2 b にする。ML の流し直し: その 2 本の見開きの段で EyeLid < 0.5 が 左 37.1% / 右 15.8% -> 5.0% / 3.6%、 見開きの検出 0.592 -> 0.917。開発者の録画 26 本と校正 10 本は、半目・細め・まばたきの数字が同じ。 1.3 b では開発者の細めのフレームの 61% で探し直しが走り、1.4 b で細めが 0.47 pt 落ちてまばたきも 1 回取りこぼす - Frame での流し直し(このビルド): 録画 1 本目 左 46% / 右 11% -> 6% / 4%、2 本目 48% / 20% -> 6% / 4%(EyeLid < 0.5、 そのかぶりの校正、感度 0.9)。開発者の録画 16 本では、33 万フレーム中 上まぶたの線が変わったのは 270、 EyeLid が 0.1 より動いたのは 2、閉じの判定の変化はなし - テスト: 境目の 3 px 上の線はそのまま残り、2 px 下の線は瞳の上で探し直される(境目の値に合わせて動く) - README(eyecam)に探し直しの決まりと数字を書く Co-Authored-By: Claude Opus 5.5 --- tools/eyecam/README.md | 10 +++++++ tools/eyecam/src/feat.rs | 58 +++++++++++++++++++++++++++------------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index 492817d..8b38148 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -899,6 +899,16 @@ lead_in・close・normal・widen)で wear 校正して、残りの段(step > 瞳の検出 57.7% → 86.8%、細め 0.347 → 0.926。そのかわり誤った半目は 5.8% → 9.0% に上がる。 帯の縁が x 280 より内(虹彩にかかる 35% の帯など)なら、瞳を探す場所が要るので何もしない(その映像は v0.8.0 と同じ)。 帯がなければ何も変わらない +- 瞳が欠けずに見えているのに、上まぶたの線が瞳の中心から瞳の短半径 b の 1.2 倍より下に見つかったときは、瞳の上(瞳の上の縁から + 2 px 上まで)でもう一度探し、そこで見つかった線の inlier の割合が同じか高ければそちらを使う(`ABOVE_PUPIL_B`)。 + 0.8.2-beta.3 までは 0.5 倍だった。大きく見開いた目では、最初の線がぼけた瞳の上の縁(中心から 1.05〜1.21 b)に乗ることがあり、 + 0.5 倍では探し直しが一度も走らず、開いた目が半目と読まれていた(度付きインサートを使っている人の録画 2 本、見開きの段で + EyeLid が 0.5 未満になったフレーム 左 37.1% / 右 15.8% → 5.0% / 3.6%、見開きの検出 0.592 → 0.917)。 + 開発者の録画 26 本と校正 10 本では、誤った半目・細め・まばたき・細めが開いたと読まれる割合が 0.5 倍と同じ(Frame での流し直しでも + 16 本・33 万フレームのうち上まぶたの線が変わったのは 270 フレーム〔0.08%〕、EyeLid が 0.1 より動いたのは 2 フレーム〔最大 0.13〕、 + 閉じの判定が変わったフレームはなし)。1.3 倍だと開発者の細めのフレームの 61% で + 探し直しが走り、1.4 倍で細めの検出が 0.47 pt 落ち、まばたきも 1 回取りこぼす(浅い細めで瞳の上をかすめるまぶたを、上のしわや + まつ毛に取り替えはじめる)ので 1.2 倍にしている - ソフトで明るさを上げるスライダーは要らない。暗い映像を 2 倍にしても同じくらいまで戻るが、それは決まった階調が合うようになるからで、 その倍率はこれが映像から出す。元の明るさで 1.5 倍にすると白飛びで閉じが 0.973 → 0.941 に落ちる diff --git a/tools/eyecam/src/feat.rs b/tools/eyecam/src/feat.rs index 38e20e7..8032b01 100644 --- a/tools/eyecam/src/feat.rs +++ b/tools/eyecam/src/feat.rs @@ -77,19 +77,26 @@ const LID_EDGE_ROWS: usize = 1; /// reach its edge: a lid that low hides its upper half): the same wearer's left eye had its upper line at -0.25 r, under /// the centre, on more than half of its widen frames; the developer's recordings, 0.001% of such frames. const UPPER_WHOLE_VIS: f64 = 0.75; -/// With the pupil seen whole, a line found lower than ABOVE_PUPIL_B of its minor semi-axis above its centre is -/// searched for again above the pupil (rows from the band's top to ABOVE_PUPIL_PX px above the pupil), and the line -/// found there replaces it if it is not pushed against that band's edge and its inlier share is at least the first -/// one's: a stronger bright -> dark step lower down took the search there. A lid that only grazes the pupil's top -/// (vis still over UPPER_WHOLE_VIS) sits above ABOVE_PUPIL_B and keeps its line; its edge would be outside the second -/// band, which could then take a crease or the lashes above. A tester with lens inserts (a recording of 2026-10-23, a -/// brightly lit sclera and glints from the inserts, long upper lashes) had the line under the pupil's centre in 76% / -/// 77% of the normal frames and 86% / 96% of the widen frames, and most of the rest just above the centre: live read -/// the widened eye as nearly shut (skin line 0.05 R), and its wear calibration had a line in 368 / 350 of 1512 normal -/// frames. Searched above the pupil, the line is on the lid's edge in the normal frames (0.83 / 0.76, in 1419 / 1501 -/// of them) and on the lid's edge or the iris's top when widened (1.35 / 1.26): a widen step of 0.52 / 0.51. +/// With the pupil seen whole, a line found lower than ABOVE_PUPIL_B of its minor semi-axis above its centre is searched +/// for again above the pupil (rows from the band's top to ABOVE_PUPIL_PX px above the pupil), and the line found there +/// replaces it if it is not pushed against that band's edge and its inlier share is at least the first one's: a +/// stronger bright -> dark step lower down took the search there. It was 0.5 b up to 0.8.2-beta.3: on a wide-open eye +/// the first line can sit on the pupil's own blurred top edge, 1.05 to 1.21 b above its centre (two recordings from +/// someone wearing prescription inserts, 2026-10-25: the failing widen frames' lines, the median at 1.13 b), which 0.5 +/// b never searched again, and the widened eye read half-shut (EyeLid under 0.5 in 37.1% / 15.8% of the widen frames, +/// left / right; 5.0% / 3.6% at 1.2 b, widen recall 0.592 -> 0.917). On the developer's 26 recordings and 10 +/// calibrations 1.2 b changes none of the outputs' measures (half-shut, squint, blinks); higher, the search runs on +/// more and more shallow squints, where a lid that grazes the pupil's top (vis still over UPPER_WHOLE_VIS) has its edge +/// outside the second band, which then takes a crease or the lashes above: at 1.3 b on 61% of the developer's squint +/// frames, and 1.4 b loses 0.47 pt of squint and a blink. +/// A tester with lens inserts (a recording of 2026-10-23, a brightly lit sclera and glints from the inserts, long upper +/// lashes) had the line under the pupil's centre in 76% / 77% of the normal frames and 86% / 96% of the widen frames, +/// and most of the rest just above the centre: live read the widened eye as nearly shut (skin line 0.05 R), and its +/// wear calibration had a line in 368 / 350 of 1512 normal frames. Searched above the pupil, the line is on the lid's +/// edge in the normal frames (0.83 / 0.76, in 1419 / 1501 of them) and on the lid's edge or the iris's top when widened +/// (1.35 / 1.26): a widen step of 0.52 / 0.51. const ABOVE_PUPIL_PX: f64 = 2.0; -const ABOVE_PUPIL_B: f64 = 0.5; +const ABOVE_PUPIL_B: f64 = 1.2; /// The calibration's second, higher search band for a widened upper lid (`Extractor::upper_high`): rows from /// cy - HIGH_BAND.0 r to cy - HIGH_BAND.1 r (the usual band is cy - 2.1 r .. cy + 0.45 r). See live::widen_fit. pub const HIGH_BAND: (f64, f64) = (3.2, 1.6); @@ -1439,14 +1446,29 @@ mod tests { let blank = img.iter().map(|&v| if v == 12 { 150 } else { v }).collect::>(); let (f, p) = ex.extract(&blank, 347.0, Some((250.0, 200.0)), Some(55.0), Some(&pupil)); assert!(p.is_none() && (f.upper_skin_y - 220.0).abs() < 3.0, "{f:?}"); + // A line 2 px under ABOVE_PUPIL_B of the pupil's radius above its centre is searched for again too. + let low = (200.0 - ABOVE_PUPIL_B * 12.0 + 2.0).round() as usize; + let mut img = eye_with_step_r(low, 12.0); + for y in 0..150 { + for x in 0..350 { + if img[y * W + x] == 150 { + img[y * W + x] = 170; + } + } + } + let (f, p) = ex.extract(&img, 347.0, None, Some(55.0), None); + let p = p.expect("pupil"); + assert!(p.vis >= UPPER_WHOLE_VIS && (low as f64) > p.cy - ABOVE_PUPIL_B * p.b, "{p:?}"); + assert!(!f.upper_edge && (f.upper_skin_y - 150.0).abs() < 3.0, "{f:?}"); } #[test] - fn a_lid_grazing_a_whole_pupils_top_stays_the_line() { + fn a_lid_above_the_search_again_line_stays_the_line() { let mut ex = Extractor::default(); - // The lid's step 8 px above the centre of a pupil of radius 12 (between its top and half way up, as a lid that - // just touches the pupil in a shallow squint), and a weaker crease 50 px above: the lid stays the line. - let mut img = eye_with_step_r(192, 12.0); + // The lid's step 3 px above ABOVE_PUPIL_B of a whole pupil's radius (12) above its centre (at 0.5 a lid that + // touches the pupil in a shallow squint), and a weaker crease 50 px above: the lid stays the line. + let step = (200.0 - ABOVE_PUPIL_B * 12.0 - 3.0).round() as usize; + let mut img = eye_with_step_r(step, 12.0); for y in 0..150 { for x in 0..350 { if img[y * W + x] == 150 { @@ -1456,8 +1478,8 @@ mod tests { } let (f, p) = ex.extract(&img, 347.0, None, Some(55.0), None); let p = p.expect("pupil"); - assert!(p.vis >= UPPER_WHOLE_VIS && 192.0 > p.cy - p.b && 192.0 < p.cy - ABOVE_PUPIL_B * p.b, "{p:?}"); - assert!(!f.upper_edge && (f.upper_skin_y - 192.0).abs() < 3.0, "{f:?}"); + assert!(p.vis >= UPPER_WHOLE_VIS && (step as f64) < p.cy - ABOVE_PUPIL_B * p.b, "{p:?}"); + assert!(!f.upper_edge && (f.upper_skin_y - step as f64).abs() < 3.0, "{f:?}"); } /// Pupil (12, radius 18) at (230, 200) in an iris (70, radius 45) on sclera (120), a ring of brighter iris (85) at From cc8b978b924b6a8580596b4b728d71627fa65156 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Mon, 26 Oct 2026 10:17:18 +0900 Subject: [PATCH 54/65] =?UTF-8?q?0.8.2-beta.4:=20=E7=89=88=E3=82=92?= =?UTF-8?q?=E4=B8=8A=E3=81=92=E3=80=81=E6=9B=B4=E6=96=B0=E5=B1=A5=E6=AD=B4?= =?UTF-8?q?=E3=81=AB=E8=A6=8B=E9=96=8B=E3=81=8D=E3=81=AE=E6=8E=A2=E3=81=97?= =?UTF-8?q?=E7=9B=B4=E3=81=97=E3=81=A8=E5=8D=8A=E7=9B=AE=E3=81=AE=E8=AA=AD?= =?UTF-8?q?=E3=81=BF=E7=9B=B4=E3=81=97=EF=BC=88AI=EF=BC=89=E3=81=AE?= =?UTF-8?q?=E9=87=8D=E3=81=BF=E3=81=AE=E5=85=A5=E3=82=8C=E6=9B=BF=E3=81=88?= =?UTF-8?q?=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 7 +++++++ CHANGELOG.md | 9 +++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 71a4165..5782a68 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -1,5 +1,12 @@ # 更新履歴 +## 0.8.2-beta.4 (2026-10-26) + +度付きインサートを使っていると、大きく見開いた目が目のカメラで半目に読まれることがまだありました。見開きがもっと安定して出るはずです(更新したらカメラの校正をやり直してね)。「半目の読み直し(AI)」を学習し直して、目の映像に明るい帯や明るいところがあっても、閉じや細めを取り消さないようにしました。 + +- eyecam、見開き: 瞳がまるごと見えているとき、上まぶたの線が瞳の中心から短い半径の 1.2 倍(前は 0.5 倍)より下で見つかったら、瞳の上でもう一度探すようにしました。大きく見開いた目では、最初の線がぼけた瞳の上の縁(中心から半径の 1.05〜1.21 倍)に乗ることがあり、見開いた目が半目に読まれていました。度付きインサートを使っている人の録画 2 本で、見開きのフレームが半目に読まれたのは左 37.1 % / 右 15.8 % から 5.0 % / 3.6 % に、見開きが拾えたのは 59.2 % から 91.7 % になりました。閉じと細めは変わりません。開発者の録画 26 本と校正 10 本では、どの指標(半目・細め・見開き・まばたき)も変わりません。更新したら「目のカメラの校正(18秒)」をやり直してください +- eyecam、「半目の読み直し(AI)」(既定はオフ): ネットの重みを、開発者の録画に白い帯・暗さ・最大 10 px のずらしを描き足して学習し直しました。前の重みは、明るい帯や明るいところがある映像で閉じた目を「開き」と読み、度付きインサートを使っている人の録画では閉じの 14 % と細めの半分を取り消していました。新しい重みでは、その録画でも、開発者の録画に絵の 35 % の帯を足したものや、暗くして帯を足したものでも取り消しません。開発者の録画で開いた目が半目に読まれるのは 2.98 % から 3.11 %(日ごとに抜いて学習した評価)になり、まばたきと細めは変わりません。オフのままなら何も変わりません + ## 0.8.2-beta.3 (2026-10-25) パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。また、目のカメラの校正で見開くたびに上まぶたの高さが違って読まれる人も、見開きが届きやすくなりました(更新したらカメラの校正をやり直してね)。HMD を外しているあいだに目のカメラのつかみ方が偏って、片目が遅くなったり、もう片方の目の映像が送られたりすることがあったのも直しました。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b0d22..1805dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.8.2-beta.4 (2026-10-26) + +With prescription inserts, a wide-open eye could still be read as half-shut by the eye cameras; widening should now show more reliably (calibrate the cameras again after updating). The "Half-shut check (AI)" was retrained so that a bright band or patch in the eye picture no longer makes it cancel closes and squints. + +日本語: 度付きインサートを使っていると、大きく見開いた目が目のカメラで半目に読まれることがまだありました。見開きがもっと安定して出るはずです(更新したらカメラの校正をやり直してね)。「半目の読み直し(AI)」を学習し直して、目の映像に明るい帯や明るいところがあっても、閉じや細めを取り消さないようにしました。 + +- eyecam, widening: with the pupil seen whole, an upper lid line found less than 1.2 times the pupil's minor radius above its centre (0.5 times before) is now searched for again above the pupil. On a wide-open eye the first line can sit on the pupil's own blurred top edge, 1.05 to 1.21 times that radius above its centre, and the widened eye read half-shut. On two recordings from someone wearing prescription inserts, widen frames read half-shut went from 37.1 % / 15.8 % (left / right) to 5.0 % / 3.6 %, and widening was caught in 91.7 % of them instead of 59.2 %; closes and squints stayed the same. On the developer's 26 recordings and 10 calibrations, every measure (half-shut, squint, widening, blinks) stayed the same. Run "Calibrate the cameras (18 s)" again after updating. +- eyecam, "Half-shut check (AI)" (off by default): the network's weights were retrained on the developer's recordings with white bands, dimming and shifts of up to 10 px drawn in. The earlier weights read a closed eye as open in pictures with a bright band or patch: on one wearer with prescription inserts they cancelled 14 % of closes and half of the squints. Now they cancel none on that recording, nor on the developer's recordings with a band over 35 % of the picture or dimmed with a band. Open eyes read half-shut on the developer's recordings go from 2.98 % to 3.11 % (each day left out of training in turn); blinks and squints stay the same. With the check off, nothing changes. + ## 0.8.2-beta.3 (2026-10-25) New in the panel: "Record for squint training" (Advanced › Debug tools, once the eye cameras are set up) records your eyes for the developer with one press, to train the "Half-shut check (AI)" on more people's eyes. It is entirely optional: it says first what is recorded and how it is used, and records only after you agree. Widening is also easier to reach for people whose upper lid reads at a different height in each widen of the eye-camera calibration (calibrate the cameras again after updating). With the headset off, the eye cameras could also be locked onto only part of their slots, leaving one eye slow or sent the other eye's frames; that is fixed. diff --git a/Cargo.lock b/Cargo.lock index 9a0e69b..e01b00f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "frameeyeosc" -version = "0.8.2-beta.3" +version = "0.8.2-beta.4" dependencies = [ "clap", "libc", diff --git a/Cargo.toml b/Cargo.toml index bde561e..096f358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frameeyeosc" -version = "0.8.2-beta.3" +version = "0.8.2-beta.4" edition = "2024" license = "MIT" description = "Sends Steam Frame eye tracking (gaze and eyelids) to VRChat over OSC" From 342b4aaf452a81fa3aa568a82f9f595aa90038f9 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Mon, 26 Oct 2026 20:05:10 +0900 Subject: [PATCH 55/65] =?UTF-8?q?=E7=9B=AE=E3=81=AE=E3=83=97=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E3=81=AE=E6=98=A0=E5=83=8F=E3=81=AB?= =?UTF-8?q?=E3=80=81eyecam=20=E3=81=8C=E8=A6=8B=E3=81=A4=E3=81=91=E3=81=9F?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E3=81=AE=E3=81=BE=E3=81=B6=E3=81=9F=E3=81=AE?= =?UTF-8?q?=E7=B7=9A=E3=82=92=E9=87=8D=E3=81=AD=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eyecam-rec は preview ファイルの version 1 で空いていた最後のページの残りに、目ごとのまぶたの線 (上まぶたの皮膚の線とふちまでの幅、下まぶたの線。当てはめた放物線の両端とまん中の行)と、 そのフレームで送った EyeLid を書く。場所はヘッダーの予約だった 52・56 に書くので、version は 1 のまま 古いパネルは今までどおり読め、新しいパネルは線のない古いファイルも線なしで読む。 線がないフレーム(見つからない・帯の端・下の線を前の線で置き換えた)は NaN で、何も描かない。 検出は変えていない(録画の --replay の CSV がこの変更の前とバイト単位で同じ)。 パネルは上まぶたのふちを水色、下まぶたをピンクの線で、暗い縁取りの上に描き、 映像の左下に「カメラ まぶた 0.62」を出す。 Co-Authored-By: Claude Opus 5.5 --- README.ja.md | 2 +- README.md | 2 +- panel/README.md | 4 +- panel/src/eye_preview.cpp | 71 +++++++++++++++++++- panel/src/eye_preview.h | 30 ++++++++- panel/src/i18n.cpp | 6 +- panel/src/i18n.h | 1 + panel/src/panel.cpp | 39 +++++++++-- panel/src/theme.cpp | 2 + panel/src/theme.h | 3 + panel/tests/eye_preview_test.cpp | 74 +++++++++++++++++++++ tools/eyecam/README.md | 29 +++++++- tools/eyecam/src/feat.rs | 34 +++++++++- tools/eyecam/src/preview.rs | 111 +++++++++++++++++++++++++++++-- 14 files changed, 389 insertions(+), 19 deletions(-) diff --git a/README.ja.md b/README.ja.md index a59e058..f313dca 100644 --- a/README.ja.md +++ b/README.ja.md @@ -126,7 +126,7 @@ sudo は要りません。全部ホームフォルダ(`~/.local/bin`、`~/.loc | ![詳細のタブ](docs/images/panel-advanced-ja_2026-10-16_23-30-00.png) | | - 左の列には、いつでも今の状態が出ます: 送信中か止めているか、送り先、毎秒の送信回数、目のデータが毎秒何回来ているか(60 未満は赤で「少なめ」。その下に、遅いのが本体か Frame か)、左右のまぶたと視線(生の値と送った値)、設定のエラー。「Track Dominant Eye Only」の設定がオンのあいだは、視線の見出しの行に、Frame がどちらの目で追っているかが出ます(「Frame の設定: 右目だけで追っています」) -- 目のプレビュー: 左の列のいちばん下のボタンで、タブの上にシートを開き、両目の動きをそのまま描きます: まぶた(0.75 がふだんの開き。見開くとまぶたが上がる)、細め(下まぶたが上がる)、瞳孔の大きさ、目の向き。描く値は「送る値」「生の値」(Valve の開き具合と、なめらかにする前の視線)「カメラの値」(目のカメラが読んだ値)から選べます。目の下に、まぶたの値・どこから来たか(「Valve」か「カメラ」)・瞳孔の mm。「鏡で見る」(最初はオン)は鏡に映した向きで、右目が右に出ます(オフでほかの人や正面のアバターから見た向きになり、右目が左に出ます)。「止める」で描くのを止めます。目のカメラを使っているときは「カメラ映像」(うっかり映らないよう最初はオフ。一度オンにすると、パネルを起動し直すか、オフにするか、カメラが止まるまで、開くたびにオンのまま)で、目ごとの今のカメラの映像と、eyecam が見つけた瞳(緑の丸)・瞳を探す範囲(黄色の枠)・「閉じてる判定」も出します。映像は表示するだけで保存しません([プライバシー](#プライバシー))。アバターの目の形は人によって違うので、動きの確認用です。タブ・✕・ダッシュボードを閉じると閉じます +- 目のプレビュー: 左の列のいちばん下のボタンで、タブの上にシートを開き、両目の動きをそのまま描きます: まぶた(0.75 がふだんの開き。見開くとまぶたが上がる)、細め(下まぶたが上がる)、瞳孔の大きさ、目の向き。描く値は「送る値」「生の値」(Valve の開き具合と、なめらかにする前の視線)「カメラの値」(目のカメラが読んだ値)から選べます。目の下に、まぶたの値・どこから来たか(「Valve」か「カメラ」)・瞳孔の mm。「鏡で見る」(最初はオン)は鏡に映した向きで、右目が右に出ます(オフでほかの人や正面のアバターから見た向きになり、右目が左に出ます)。「止める」で描くのを止めます。目のカメラを使っているときは「カメラ映像」(うっかり映らないよう最初はオフ。一度オンにすると、パネルを起動し直すか、オフにするか、カメラが止まるまで、開くたびにオンのまま)で、目ごとの今のカメラの映像と、eyecam が見つけた瞳(緑の丸)・瞳を探す範囲(黄色の枠)・上と下のまぶた(水色とピンクの線。そのフレームで見つからなければ出ません)・そのフレームでカメラが読んだまぶたの値・「閉じてる判定」も出します。映像は表示するだけで保存しません([プライバシー](#プライバシー))。アバターの目の形は人によって違うので、動きの確認用です。タブ・✕・ダッシュボードを閉じると閉じます - 基本: 送信の一時停止、送り先(3 枚のカード: VRChat に直接・VRCFT(LiveLink、おすすめ)・VRCFT(FrameEyeOSC、全部入り)。それぞれほかの人からの見え方(FrameEyeOSC は細めと瞳孔も届くので ◎)と、PC に何が要るかを表示。「VRChat に直接」のときは、カードの下に「目が動かないアバターは、VRCFT(LiveLink か FrameEyeOSC)にすると動きます」と灰色で 1 行。カードの下に「削除予定」の付いた小さな[VRCFT(ETVR)]のボタン。ETVR に送っているときは、その上に「次の版で ETVR はなくなるので、3 つからほかの送り先を選び直して」というオレンジの案内が出ます)、目のカメラ(準備前は「使っていない」と目のカメラタブへの[はじめる →]、準備後は「使う / 使わない」(`camera_lids`)と「左・右 OK」などの短い状態)、言語(日本語 / English。右に[すべて既定に戻す])、SteamVR と一緒に起動(右に[アプリを終了]) - 送り方: 送り先の PC(自動、今送っている PC で固定、または入力。下を参照)とポート。VRChat に直接のときはパラメーター名の頭、EyeTrackingActive の型、Steam Link の名前も送るか、VRChat 標準の目も動かすか、LiveLink・FrameEyeOSC・ETVR のときは PC の VRCFaceTracking で準備すること(FrameEyeOSC は 4 ステップで、モジュールの入手先のアドレス付き。押すと Frame のブラウザーでそのページを開きます)。LiveLink で目のカメラを使っているときは「瞳孔は VRChat に直接送る」(`pupils_to_vrchat`)も。目のカメラを使っていて VRChat に直接か LiveLink のときは「瞳孔の受け取り方」(`pupil_bits`)も。こういう目のカメラがないと使えない行には緑の「カメラ」バッジが付き、カメラを使っていないとき(準備前か「使わない」)は、代わりに点線の 1 行(押すと目のカメラタブ)が出ます - 視線: いちばん上の切り替えで 3 ページ(初めては「動き」、あとは最後に開いたページ。パネルを起動し直しても開きます)。「動き」: スムージングのオン / オフ、なめらかさの弱 / 中 / 強と 3 つの値、見つめている時の遊び。「動く範囲」: 視線の倍率(目が左・右・上・下へどこまで動くか。5〜200 %。目合わせのあとに掛け、目合わせでは変わりません。左右・上下のあいだの鍵を閉じているあいだは、両方いっしょに変わります)、左右の目を別々に動かす。「乱れ対策」: まばたき中は視線を止める、不確かな視線を使わない、一瞬の途切れを消す diff --git a/README.md b/README.md index f5549c3..67f05e6 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ To go back, turn "Get test versions" off. Your test version stays installed unti | ![The Advanced tab](docs/images/panel-advanced-en_2026-10-16_23-30-00.png) | | - The left column always shows what frameeyeosc is doing: sending or paused, where it sends to, messages per second, how many samples a second the eye tracker delivers (marked "low" below 60, with a line under it saying whether frameeyeosc or the Frame was the slow one), both eyelids and the gaze (raw and sent), and a config error if there is one. While the "Track Dominant Eye Only" setting is on, the gaze title line says which eye the Frame tracks ("Frame setting: tracking the right eye only"). -- Eye preview: the button at the bottom of the left column opens a sheet over the tabs that draws both eyes as they move: the eyelid (0.75 is relaxed open; widening raises the lid), the squint (the lower lid rises), the pupil's size and where the eye looks, from the values as sent, as read ("Raw": Valve's openness and the gaze before smoothing) or as the eye cameras read them ("Camera"). Under each eye: its eyelid, where it came from ("Valve" or "Camera") and its pupil in mm. "Mirror" (on at first) shows the eyes as in a mirror, your right eye on the right (off: as someone facing you, or your avatar from the front, sees them, your right eye on the left); "Freeze" stops the drawing. With the eye cameras in use, "Camera picture" (off at first, so your eyes never show by accident; once you turn it on, it stays on each time the preview opens until the panel restarts, you turn it off, or the cameras stop) also shows each eye's live camera picture with the pupil eyecam found (green circle), where it looks for it (yellow frame) and "Read as closed". The picture is only shown, never saved (see [Privacy](#privacy)). Avatars' eyes differ, so the drawing is for checking the movement. Any tab, the cross or closing the dashboard closes it. +- Eye preview: the button at the bottom of the left column opens a sheet over the tabs that draws both eyes as they move: the eyelid (0.75 is relaxed open; widening raises the lid), the squint (the lower lid rises), the pupil's size and where the eye looks, from the values as sent, as read ("Raw": Valve's openness and the gaze before smoothing) or as the eye cameras read them ("Camera"). Under each eye: its eyelid, where it came from ("Valve" or "Camera") and its pupil in mm. "Mirror" (on at first) shows the eyes as in a mirror, your right eye on the right (off: as someone facing you, or your avatar from the front, sees them, your right eye on the left); "Freeze" stops the drawing. With the eye cameras in use, "Camera picture" (off at first, so your eyes never show by accident; once you turn it on, it stays on each time the preview opens until the panel restarts, you turn it off, or the cameras stop) also shows each eye's live camera picture with the pupil eyecam found (green circle), where it looks for it (yellow frame), the upper and lower eyelids it found (blue and pink lines; none on a frame where it found none), the eyelid value the camera read on that frame and "Read as closed". The picture is only shown, never saved (see [Privacy](#privacy)). Avatars' eyes differ, so the drawing is for checking the movement. Any tab, the cross or closing the dashboard closes it. - Basic: pause sending, where to send (three cards: VRChat directly, VRCFT (LiveLink), marked recommended, and VRCFT (FrameEyeOSC), marked all-in-one, each saying how others see your eyes (FrameEyeOSC: pupils and squint too) and what is needed on the PC. While VRChat directly is the output, a grey line under them says that if your avatar's eyes don't move, VRCFT (LiveLink or FrameEyeOSC) makes them move. Under them a small "VRCFT (ETVR)" button marked "Deprecated"; while ETVR is the output, an orange notice above it says that ETVR goes away in the next version and to pick one of the three), the eye cameras (before their setup "Not in use" and "Get started →" to the Eye cameras tab; after it "Use / Don't use" (`camera_lids`) and how they are doing, such as "L + R OK"), language (Japanese / English) with "Reset all" at its right, start with SteamVR with "Quit app" at its right. - Output: the target PC (automatic, fixed to the PC it sends to now, or typed: see below) and port. For VRChat directly also the parameter prefix, the EyeTrackingActive type, whether to send Steam Link's parameter names too and whether to move VRChat's own eyes too; for LiveLink, FrameEyeOSC and ETVR what to set up in VRCFaceTracking on the PC instead (for FrameEyeOSC four steps, with the address of the module's download page; pressing it opens the page in the Frame's browser). With LiveLink and the eye cameras, also "Send pupils straight to VRChat" (`pupils_to_vrchat`); with the eye cameras, for VRChat directly and LiveLink, "How the avatar takes pupils" (`pupil_bits`). Rows like these, which work only with the eye cameras, carry a green "Camera" badge; while the cameras aren't in use (not set up, or "Don't use"), one dashed line that opens the Eye cameras tab takes their place. - Gaze: three pages under a switch at the top (the first time "Motion", then the one you opened last, also after the panel restarts). "Motion": smoothing on or off, light / medium / strong presets and the three filter values, deadzone. "Reach": the gaze scale (how far the eyes go left, right, up and down, 5-200 %, applied after the eye fit, which never changes it; while the lock between left and right, or between up and down, is closed, both change together) and moving the eyes separately. "Glitch guard": holding the gaze while blinking, skipping unreliable gaze, removing one-sample glitches. diff --git a/panel/README.md b/panel/README.md index 5e6ef6b..1b68554 100644 --- a/panel/README.md +++ b/panel/README.md @@ -38,9 +38,9 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ - 両目(255×170 の箱): アーモンド形の輪郭、白目、虹彩(高さの 0.27)と瞳孔(瞳孔の mm ÷ 11.8 mm × 虹彩の直径。無ければ 4 mm で描く)、虹彩はその目の視線で動く(左右 35°・上下 30° で端まで)。上まぶたは閉じ(0)でいちばん下、ふだんの開き(0.75)で開いた幅の 72%、見開き(1)でいちばん上まで上がる。細めで下まぶたが目の中ほどへ最大 45% 上がる。値が無い目は 0.75 のまま薄く描く。箱の外側の上の角に「左目」「右目」(その目についていく) - 眉: 本体が眉を送っているあいだ(`brow_from_widen` がオンで、眉を受け取る出力。データグラムのフラグ bit 2)だけ、目の上に眉の弧(#8b949e、目の幅の 22〜78%、太さは高さの 4%)。眉 0 で目のすぐ上に休み、眉 1 で目の高さの 30% 上がる(`eye_preview::browLift`)。送る値でだけ描く。生の値とカメラの値では描かない(眉はどこも測っていない。本体が見開きから作る値なので、「読んだ値」は無い)が、目の上の場所は空けたままにして、切り替えても目が動かないようにする。眉の場所の分だけ目のブロックが高くなり、映像を出していて足りないときは目を小さく(縦横の比はそのまま)描く。鏡・止めるも眉に効く。眉を送っていないときは今までとまったく同じ絵 - 目の下のチップ: 「まぶた 0.72」(送る値は VRCFT のまぶた、生の値は Valve の開き具合〔目ごとの倍率の後〕、カメラの値は eyecam のまぶた)、出どころのバッジ(送る値では本体の LidFrom: 0 = ふつうの混ぜ方なら「Valve」、それ以外〔カメラが開いている・閉じている・見開きなど〕は緑の「カメラ」。生の値はいつも Valve、カメラの値はいつもカメラ)、「瞳 6.1 mm」(送る値は送った瞳孔、カメラの値は eyecam の瞳孔、生の値と無いときは「瞳 —」)。描く値: 送る値 = 送ったまぶた・細め・瞳孔・視線、生の値 = Valve の開き具合(倍率の後、1.0 をふだんの開き 0.75 として描く)となめらかにする前の視線、細めと瞳孔は無し、カメラの値 = eyecam のまぶた・細め・瞳孔となめらかにする前の視線(カメラは視線を出さないので)。カメラの値が無い目は値なし - - カメラ映像(オンで、目のカメラを使えるとき): 目ごとに 176×176 の灰色の映像を目の下に。eyecam-rec は両目とも耳側を左(x の小さい側)にして上下を正しく書くので、右の列に描く目の映像を左右反転して、どちらの向きでも耳側をシートの外側にする(映像は描いた目についていく。鏡と正面は互いに鏡像になる)。その上に、見つけた瞳を緑の丸(#5fd38a。見つからなければ無し)、瞳を探す範囲を黄色の点線の枠(#f2d25a)、eyecam が閉じていると読んでいるときは左上に「閉じてる判定」。映像が無い・0.5 秒より古いときは「映像を待っています」。映像はその描画の間だけメモリの上で RGB にして描き、どこにも書かない + - カメラ映像(オンで、目のカメラを使えるとき): 目ごとに 176×176 の灰色の映像を目の下に。eyecam-rec は両目とも耳側を左(x の小さい側)にして上下を正しく書くので、右の列に描く目の映像を左右反転して、どちらの向きでも耳側をシートの外側にする(映像は描いた目についていく。鏡と正面は互いに鏡像になる)。その上に、見つけた瞳を緑の丸(#5fd38a。見つからなければ無し)、瞳を探す範囲を黄色の点線の枠(#f2d25a)、eyecam がそのフレームで測ったまぶたの線(上まぶたのふちを水色 #6cc4ff、下まぶたをピンク #ff8fc8。放物線を 16 本の線分で、暗い縁取りの上に。その線が無いフレームや、線の無い古い eyecam-rec のファイルでは描かない)、eyecam が閉じていると読んでいるときは左上に「閉じてる判定」、左下にそのフレームで eyecam が送ったまぶたの値(「カメラ まぶた 0.62」。値が有効でなければ無し)。映像が無い・0.5 秒より古いときは「映像を待っています」。映像はその描画の間だけメモリの上で RGB にして描き、どこにも書かない - 使えないとき(eyecam-rec が動いていない・準備前・`camera_lids` がオフ・status.json に `preview` が無い古い eyecam-rec・`preview on` を断られた)は「カメラ映像」を押せなくし、スイッチの下に理由を 1 行(「目のカメラが動いていません」など)。最初の 3 つでは「カメラの値」も押せず、送る値で描く。目はカメラなしでも Valve の値で動く。映像が無いときは、目とチップを空いたところの真ん中に。使えなくなったら「カメラ映像」はオフに戻し、また使えるようになってももう一度押すまで映像は出さない。`preview on` を断られた(2 秒で返事が無いときも)ら理由を 5 秒(`kRefusedSec`)出してから、また押せるようにする。本体のサンプルが無い目には出どころのバッジを出さない - - いちばん下に「視線 左右 +5°・上下 −3°」(送る値は送った視線、ほかはなめらかにする前の視線。正面から見た向きでも本人から見た向き: + が右・上)と、映像を出しているときは「映像は表示するだけで保存しません。緑の丸が見つけた瞳、黄色の枠が瞳を探す範囲」、出していないときは「アバターの目の形は人によって違います。動きの確認用です」。本体のサンプルが 1 秒来なければ「目のデータがありません」 + - いちばん下に「視線 左右 +5°・上下 −3°」(送る値は送った視線、ほかはなめらかにする前の視線。正面から見た向きでも本人から見た向き: + が右・上)と、映像を出しているときは「映像は表示するだけで保存しません。緑の丸が見つけた瞳、黄色の枠が瞳を探す範囲、水色とピンクの線が上と下のまぶた」、出していないときは「アバターの目の形は人によって違います。動きの確認用です」。本体のサンプルが 1 秒来なければ「目のデータがありません」 - 値の受け取り方: シートが見えているあいだ(ダッシュボードが開いていて)だけ、視線の点のソケット `gaze-dots.sock` を開く(点のスイッチがオフなら点は出さない)。本体はそのソケットがあれば(1 秒に 1 回見る)サンプルごとに版 3 のデータグラム(160 バイト: 送った視線・なめらかにする前の視線と、目ごとに送ったまぶた・Valve の開き具合〔倍率の前と後〕・カメラのまぶた・送った細めと瞳孔・カメラの細めと瞳孔・LidFrom・カメラの閉じ判定・送った眉〔u16、0.8.0 から。もとは 0 の予備のバイト〕、最後に視線の倍率を掛ける前の視線。`src/dots.rs`)を送る。眉はフラグの bit 2 が立っているときだけ読む(版と大きさはそのまま: 0.7.10 までのパネルは眉を読まずにほかは同じに読み、0.7.10 までの本体のデータグラムは眉なしとして読む)。版 2(136 バイト、0.7.7 までの本体)も読む(倍率の前の視線は送った視線と同じとみなし、輪は出さない)。ほかの版は無視する - 映像の受け取り方: 映像が出せるあいだ、eyecam-rec の `ctl.sock` に `preview on` をすぐと、そのあと 1 秒ごとに送る(目のカメラタブの返事と混ざらないよう、つなぎ方は別)。出さなくなったら `preview off` を 1 回。eyecam-rec は `preview on` から 3 秒で自分で止まるので、パネルが落ちても残らない。映像は eyecam-rec のフォルダの `preview`(`tools/eyecam/README.md` の「目のプレビュー」)を読み取り専用で mmap し、seqlock で写して、magic・版・大きさを確かめる。この利用者の普通のファイルだけ、リンクはたどらない。置き換わった(inode が変わった)・消えたら開き直す - 描く間隔: 開いているあいだは、値か映像が新しくなるたびに、パネルのふだんの間隔(0.033 秒おき、約 30 回/秒)で描く。視線の点がオンでループが表示のフレームごとに回っていても、描くのは約 30 回/秒まで(`eye_preview::DrawPacer`。0.03 秒おきより詰めない)。Frame で 1 回 6.2 ms(RGBA への写しを含む。`--preview-bench`)、つまり開いているあいだ 1 コアの約 2 割。閉じたらソケットを閉じ、映像と値を捨てる diff --git a/panel/src/eye_preview.cpp b/panel/src/eye_preview.cpp index a3d7cb6..93a0734 100644 --- a/panel/src/eye_preview.cpp +++ b/panel/src/eye_preview.cpp @@ -84,8 +84,41 @@ double orElse(double value, double fallback) { return std::isfinite(value) ? value : fallback; } +/** + * A lid line from the file: its first and last column, then its rows there and halfway. + * @param data the bytes + * @param at where it starts + * @return the line (not present unless all five are numbers and it runs left to right) + */ +LidLine lidLineAt(const uint8_t* data, size_t at) { + LidLine line; + line.x0 = f32At(data, at); + line.x1 = f32At(data, at + 4); + for (int i = 0; i < 3; ++i) line.y[i] = f32At(data, at + 8 + i * 4); + line.present = std::isfinite(line.x0) && std::isfinite(line.x1) && line.x1 > line.x0 && std::isfinite(line.y[0]) && + std::isfinite(line.y[1]) && std::isfinite(line.y[2]); + return line; +} + +/** + * Write a lid line as the file has it (NaN for one not present). + * @param data the bytes + * @param at where it starts + * @param line the line + */ +void putLidLine(std::vector& data, size_t at, const LidLine& line) { + const double values[5] = {line.x0, line.x1, line.y[0], line.y[1], line.y[2]}; + for (int i = 0; i < 5; ++i) put(data, at + i * 4, line.present ? static_cast(values[i]) : NAN); +} + } // namespace +double lidLineY(const LidLine& line, double x) { + // (Lagrange through the three rows: s 0 at x0, 0.5 halfway, 1 at x1) + const double s = (x - line.x0) / (line.x1 - line.x0); + return line.y[0] * (1 - s) * (1 - 2 * s) + line.y[1] * 4 * s * (1 - s) + line.y[2] * s * (2 * s - 1); +} + Shown shown(const gaze_dots::Packet& packet, Source source, const CameraLids& lids) { Shown s; s.valid = true; @@ -194,6 +227,11 @@ bool parsePictures(const uint8_t* data, size_t size, Pictures& out, std::string& why = "shorter than its two pictures (" + std::to_string(size) + " bytes)"; return false; } + // The lid lines, after both pictures, when the header says where (0 in an older eyecam-rec's file) and they fit + const size_t lidsAt = u32At(data, kLidsAtOffset); + const size_t lidSize = u32At(data, kLidSizeOffset); + const bool lids = lidsAt >= kHeaderSize + 2 * static_cast(eyeSize) && lidSize >= kLidBytes && + lidsAt + 2 * lidSize <= size; Pictures pictures; pictures.on = u32At(data, 48) == 1; pictures.writerPid = u32At(data, 28); @@ -214,6 +252,14 @@ bool parsePictures(const uint8_t* data, size_t size, Pictures& out, std::string& for (int i = 0; i < 4; ++i) p.window[i] = f32At(data, at + 32 + i * 4); p.side = static_cast(width); p.pixels.assign(data + at + kEyeHeaderSize, data + at + eyeSize); + p.lids = lids; + if (lids) { + const size_t l = lidsAt + eye * lidSize; + p.eyeLid = f32At(data, l); + p.upper = lidLineAt(data, l + 4); + p.margin = f32At(data, l + 24); + p.lower = lidLineAt(data, l + 28); + } } out = std::move(pictures); return true; @@ -221,8 +267,12 @@ bool parsePictures(const uint8_t* data, size_t size, Pictures& out, std::string& std::vector encodePictures(const Pictures& pictures, int side) { const size_t eyeSize = kEyeHeaderSize + static_cast(side) * side; - const size_t used = kHeaderSize + 2 * eyeSize; + const size_t lidsAt = kHeaderSize + 2 * eyeSize; + const size_t lidSize = 64; + const size_t used = lidsAt + 2 * lidSize; std::vector data((used + 4095) / 4096 * 4096, 0); + put(data, kLidsAtOffset, static_cast(lidsAt)); + put(data, kLidSizeOffset, static_cast(lidSize)); put(data, 0, kMagic); put(data, 4, kVersion); put(data, 8, kHeaderSize); @@ -246,6 +296,11 @@ std::vector encodePictures(const Pictures& pictures, int side) { if (p.pixels.size() == static_cast(side) * side) { std::memcpy(data.data() + at + kEyeHeaderSize, p.pixels.data(), p.pixels.size()); } + const size_t l = lidsAt + eye * lidSize; + put(data, l, static_cast(p.eyeLid)); + putLidLine(data, l + 4, p.upper); + put(data, l + 24, static_cast(p.margin)); + putLidLine(data, l + 28, p.lower); } return data; } @@ -452,6 +507,20 @@ void fake(View& view, uint64_t nowNs) { const double window[4] = {93.0, 35.0, 173.0, 160.0}; std::copy(window, window + 4, pic.window); pic.closed = false; + // Lid lines over the disc, the right eye's narrowed (its squint) + const double narrow = eye == 0 ? 0.0 : 9.0; + pic.lids = true; + pic.eyeLid = p.eyes[eye].cameraLid; + for (LidLine* line : {&pic.upper, &pic.lower}) { + line->present = true; + line->x0 = cx - 50; + line->x1 = cx + 56; + } + const double upper[3] = {cy - 22 + narrow, cy - 33 + narrow, cy - 20 + narrow}; + const double lower[3] = {cy + 30 - narrow, cy + 36 - narrow, cy + 28 - narrow}; + std::copy(upper, upper + 3, pic.upper.y); + std::copy(lower, lower + 3, pic.lower.y); + pic.margin = 4; } view.hasPictures = true; view.nowNs = nowNs; diff --git a/panel/src/eye_preview.h b/panel/src/eye_preview.h index 0c9b7d6..48b213f 100644 --- a/panel/src/eye_preview.h +++ b/panel/src/eye_preview.h @@ -173,6 +173,28 @@ constexpr double kStaleSec = 0.5; /** "preview on" goes out this often while the sheet shows the pictures (eyecam-rec keeps it on for 3 s). */ constexpr double kRenewSec = 1.0; +/** The lid lines' block, after both pictures: its offset and size are in the header (0 in an older eyecam-rec's). */ +constexpr size_t kLidsAtOffset = 52; +constexpr size_t kLidSizeOffset = 56; +/** The bytes of one eye's lid lines the panel reads (the block may be larger). */ +constexpr size_t kLidBytes = 48; + +/** A lid line eyecam-rec found on a picture: a parabola through its rows at x0, halfway and x1 (picture px). */ +struct LidLine { + bool present = false; ///< found on this frame (else none is drawn) + double x0 = NAN; ///< its first and last column + double x1 = NAN; + double y[3] = {NAN, NAN, NAN}; ///< its row at x0, halfway between and at x1 +}; + +/** + * A lid line's row at a column (the parabola through its three rows). + * @param line the line (present) + * @param x the column (picture px) + * @return the row (picture px) + */ +double lidLineY(const LidLine& line, double x); + /** One eye's picture, as eyecam-rec wrote it: upright, the temporal (ear) side at small x. */ struct Picture { bool present = false; ///< a picture was written (its time isn't 0) @@ -186,6 +208,11 @@ struct Picture { double pupilR = NAN; double window[4] = {NAN, NAN, NAN, NAN}; ///< the pupil search window: left, top, right, bottom (picture px) bool closed = false; ///< eyecam-rec reads the eye closed + bool lids = false; ///< the file has the lid lines (an older eyecam-rec's has none) + LidLine upper; ///< the upper lid's skin line... + double margin = NAN; ///< ...and its margin this far below it (picture px; the opening is measured from it) + LidLine lower; ///< the lower lid's line + double eyeLid = NAN; ///< the EyeLid eyecam-rec sent for this frame (NaN: its values weren't valid) }; /** Both pictures. */ @@ -198,7 +225,8 @@ struct Pictures { /** * Read a consistent copy of the file: the magic, the version, the header's and each eye's size, a square picture of at - * most kMaxSide, and the whole of it within `size`; refused otherwise (another or a broken file). + * most kMaxSide, and the whole of it within `size`; refused otherwise (another or a broken file). The lid lines are + * read when the header says where they are and they fit (an older eyecam-rec's file has none: read without them). * @param data the bytes * @param size how many * @param out the pictures diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index 285b14f..9861ef3 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -66,8 +66,9 @@ UiText makeJapanese() { t.previewPupilFormat = "瞳 %.1f mm"; t.previewPupilNone = "瞳 —"; t.previewClosed = "閉じてる判定"; + t.previewPictureLidFormat = "カメラ まぶた %.2f"; t.previewGazeFormat = "視線 左右 %+.0f°・上下 %+.0f°"; - t.previewNotePicture = "映像は表示するだけで保存しません。緑の丸が見つけた瞳、黄色の枠が瞳を探す範囲"; + t.previewNotePicture = "映像は表示するだけで保存しません。緑の丸が見つけた瞳、黄色の枠が瞳を探す範囲、水色とピンクの線が上と下のまぶた"; t.previewNoteShape = "アバターの目の形は人によって違います。動きの確認用です"; t.previewWaitingPicture = "映像を待っています"; t.previewCamNotRunning = "目のカメラが動いていません"; @@ -1119,8 +1120,9 @@ UiText makeEnglish() { t.previewPupilFormat = "Pupil %.1f mm"; t.previewPupilNone = "Pupil —"; t.previewClosed = "Read as closed"; + t.previewPictureLidFormat = "Camera lid %.2f"; t.previewGazeFormat = "Gaze: %+.0f° sideways, %+.0f° up/down"; - t.previewNotePicture = "The picture is only shown, never saved. Green circle: the pupil found; yellow frame: where it is looked for"; + t.previewNotePicture = "The picture is only shown, never saved. Green circle: the pupil found; yellow frame: where it is looked for; blue / pink: the upper / lower lid"; t.previewNoteShape = "Avatars' eyes differ in shape. This is for checking the movement"; t.previewWaitingPicture = "Waiting for the picture"; t.previewCamNotRunning = "The eye cameras aren't running"; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index 5d4b63a..8263798 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -74,6 +74,7 @@ struct UiText { const char* previewPupilFormat; ///< an eye's chip: its pupil (%.1f mm) const char* previewPupilNone; ///< ...without one const char* previewClosed; ///< on a camera picture: eyecam-rec reads the eye closed + const char* previewPictureLidFormat; ///< on a camera picture: the eyelid eyecam-rec sent for it (%.2f) const char* previewGazeFormat; ///< the footer: the gaze (%+.0f sideways, %+.0f up / down, in degrees) const char* previewNotePicture; ///< the footer while the pictures show: only shown, what the marks are const char* previewNoteShape; ///< ...otherwise: avatars' eyes differ, it is for the movement diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index 03cf953..1cd0b8e 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -7556,14 +7556,45 @@ void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_pre cairo_stroke(cr); cairo_set_dash(cr, nullptr, 0, 0); } + // The lid lines eyecam-rec measured on this frame (none where it found none): the upper lid's edge (its margin, + // which the opening is measured from) and the lower lid's, each on a dark edge so it shows on bright skin too + const auto lidLine = [&](const eye_preview::LidLine& line, double below, Color color) { + if (!line.present) return; + const int steps = 16; + cairo_new_path(cr); + for (int i = 0; i <= steps; ++i) { + const double lx = line.x0 + (line.x1 - line.x0) * i / steps; + cairo_line_to(cr, px(lx), py(eye_preview::lidLineY(line, lx) + below)); + } + cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); + cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND); + pen.color(kBg, 0.55); + cairo_set_line_width(cr, 5); + cairo_stroke_preserve(cr); + pen.color(color); + cairo_set_line_width(cr, 2.5); + cairo_stroke(cr); + cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT); + }; + if (picture.lids) { + lidLine(picture.upper, std::isfinite(picture.margin) ? picture.margin : 0.0, kPreviewLidUpper); + lidLine(picture.lower, 0.0, kPreviewLidLower); + } if (picture.pupil) drawRing(cr, px(picture.pupilX), py(picture.pupilY), picture.pupilR * scale, 2.5, kPreviewPupil); - if (picture.closed) { + const auto badge = [&](double by, const char* text) { const double size = 13; - const double w = pen.measure(t.previewClosed, size, true) + 18; + const double w = pen.measure(text, size, true) + 18; pen.color(kBg, 0.85); - pen.roundedRect(x + 8, y + 8, w, 24, 12); + pen.roundedRect(x + 8, by, w, 24, 12); cairo_fill(cr); - pen.text(x + 17, centerBaseline(y + 8, 24, size), t.previewClosed, size, kText, true); + pen.text(x + 17, centerBaseline(by, 24, size), text, size, kText, true); + }; + if (picture.closed) badge(y + 8, t.previewClosed); + // The EyeLid eyecam-rec sent for this very frame, under its lines + if (picture.lids && std::isfinite(picture.eyeLid)) { + char lid[64]; + std::snprintf(lid, sizeof(lid), t.previewPictureLidFormat, picture.eyeLid); + badge(y + side - 32, lid); } cairo_restore(cr); strokeRounded(pen, x, y, side, side, r, kBorder, 1.5); diff --git a/panel/src/theme.cpp b/panel/src/theme.cpp index b34733a..432831d 100644 --- a/panel/src/theme.cpp +++ b/panel/src/theme.cpp @@ -165,6 +165,8 @@ const std::vector& contrastPairs() { {"eye preview: the brows (card)", kEyeBrow, kCard, ContrastKind::Ui}, {"eye preview: the found pupil's circle (dark picture)", kPreviewPupil, kBg, ContrastKind::Ui}, {"eye preview: the search window's frame (dark picture)", kPreviewWindow, kBg, ContrastKind::Ui}, + {"eye preview: the upper lid's line (dark picture)", kPreviewLidUpper, kBg, ContrastKind::Ui}, + {"eye preview: the lower lid's line (dark picture)", kPreviewLidLower, kBg, ContrastKind::Ui}, {"eye preview: \"Read as closed\" on a picture (dark badge)", kText, kBg, ContrastKind::Text}, }; return pairs; diff --git a/panel/src/theme.h b/panel/src/theme.h index d771e28..48cea73 100644 --- a/panel/src/theme.h +++ b/panel/src/theme.h @@ -75,6 +75,9 @@ constexpr Color kEyeBrow = hexColor(0x8b949e); ///< the drawn brows (over t /** The pupil eyecam-rec found, as a circle on its picture (green), and where it looked for it (a yellow frame) */ constexpr Color kPreviewPupil = hexColor(0x5fd38a); constexpr Color kPreviewWindow = hexColor(0xf2d25a); +/** The lid lines it found: the upper lid's (light blue) and the lower lid's (pink), also told apart by their place */ +constexpr Color kPreviewLidUpper = hexColor(0x6cc4ff); +constexpr Color kPreviewLidLower = hexColor(0xff8fc8); // ---- The eye capture's full-view overlay (a light stimulus, not UI) ---- // Pure white and black for the bright and dark steps, and the instruction on them on purpose faint, so it barely // changes how bright the view is. Not in the contrast report: it isn't meant to be easy to read. diff --git a/panel/tests/eye_preview_test.cpp b/panel/tests/eye_preview_test.cpp index b913d30..dac6205 100644 --- a/panel/tests/eye_preview_test.cpp +++ b/panel/tests/eye_preview_test.cpp @@ -258,6 +258,76 @@ void testFile() { CHECK(!parsePictures(bytes.data(), 10, read, why)); } +/** + * Put little-endian f32s into bytes (a file written by hand, as eyecam-rec lays it out). + * @param bytes the file + * @param at where the first goes + * @param values them + */ +void putFloats(std::vector& bytes, size_t at, const std::vector& values) { + std::memcpy(bytes.data() + at, values.data(), values.size() * sizeof(float)); +} + +void testLidLines() { + // The parabola through the three rows: exact at its ends and middle, and between them + LidLine line; + line.present = true; + line.x0 = 90; + line.x1 = 170; + // y = 80 + (x - 130)^2 / 100: 96 at both ends, 80 halfway, 89 at x 100 + const double rows[3] = {96, 80, 96}; + std::copy(rows, rows + 3, line.y); + CHECK(near(lidLineY(line, 90), 96) && near(lidLineY(line, 130), 80) && near(lidLineY(line, 170), 96)); + CHECK(near(lidLineY(line, 100), 89) && near(lidLineY(line, 160), 89)); + // eyecam-rec's lid block (tools/eyecam/src/preview.rs), written by hand: at 64 + 2 * 40064 = 80192, 64 bytes an + // eye, said in the header at 52 and 56 + std::vector bytes = encodePictures(madeUp(200), 200); + const uint8_t where[8] = {0x40, 0x39, 0x01, 0x00, 64, 0, 0, 0}; + CHECK(std::memcmp(bytes.data() + 52, where, 8) == 0); + putFloats(bytes, 80192, {0.625f, 90, 165, 78.5f, 75.25f, 82.75f, 4, 90, 165, 116.5f, 119.75f, 112}); + // The right eye: no upper line (NaN), a lower one, the values not valid + putFloats(bytes, 80192 + 64, {NAN, NAN, NAN, NAN, NAN, NAN, NAN, 92, 160, 110, 114, 109}); + Pictures read; + std::string why; + CHECK(parsePictures(bytes.data(), bytes.size(), read, why)); + const Picture& left = read.eyes[0]; + CHECK(left.lids && near(left.eyeLid, 0.625) && near(left.margin, 4)); + CHECK(left.upper.present && near(left.upper.x0, 90) && near(left.upper.x1, 165) && near(left.upper.y[1], 75.25)); + CHECK(left.lower.present && near(left.lower.y[0], 116.5) && near(left.lower.y[2], 112)); + const Picture& right = read.eyes[1]; + CHECK(right.lids && !right.upper.present && right.lower.present && std::isnan(right.eyeLid)); + CHECK(std::isnan(right.margin) && near(right.lower.x0, 92) && near(right.lower.y[1], 114)); + // A line with one NaN, or running right to left, isn't one + putFloats(bytes, 80192 + 28 + 12, {NAN}); + putFloats(bytes, 80192 + 64 + 28, {160, 92}); + CHECK(parsePictures(bytes.data(), bytes.size(), read, why)); + CHECK(!read.eyes[0].lower.present && read.eyes[0].upper.present && !read.eyes[1].lower.present); + // Round trip + Pictures pictures = madeUp(200); + pictures.eyes[0].lids = true; + pictures.eyes[0].upper = line; + pictures.eyes[0].margin = 3.5; + pictures.eyes[0].eyeLid = 0.75; + const std::vector trip = encodePictures(pictures, 200); + CHECK(parsePictures(trip.data(), trip.size(), read, why)); + CHECK(read.eyes[0].upper.present && near(read.eyes[0].upper.y[2], 96) && near(read.eyes[0].margin, 3.5)); + CHECK(!read.eyes[0].lower.present && near(read.eyes[0].eyeLid, 0.75) && !read.eyes[1].upper.present); + // An older eyecam-rec's file (version 1 without the lid lines: 0 where the header would say where they are) is + // read as before, without lines + std::vector old = encodePictures(madeUp(200), 200); + std::fill(old.begin() + 52, old.begin() + 64, 0); + std::fill(old.begin() + 80192, old.end(), 0); + CHECK(parsePictures(old.data(), old.size(), read, why)); + CHECK(!read.eyes[0].lids && !read.eyes[0].upper.present && !read.eyes[1].lower.present); + CHECK(read.eyes[0].pupil && near(read.eyes[0].pupilX, 120) && read.eyes[1].closed); + // A block said to be where it can't be (inside the pictures, or past the end): no lines, the pictures still read + for (const uint32_t at : {64u, 81900u}) { + std::vector bad = encodePictures(madeUp(200), 200); + std::memcpy(bad.data() + 52, &at, 4); + CHECK(parsePictures(bad.data(), bad.size(), read, why) && !read.eyes[0].lids && read.eyes[0].pupil); + } +} + /** * Write a file by putting a new one in its place (as eyecam-rec does). * @param path where @@ -438,6 +508,9 @@ void testFake() { const Picture& p = view.pictures.eyes[eye]; CHECK(pictureFresh(p, view.nowNs) && p.side == 200 && p.pixels.size() == 200u * 200u && p.pupil); CHECK(p.pixels[static_cast(std::lround(p.pupilY)) * 200 + static_cast(std::lround(p.pupilX))] < 20); + // Lid lines above and below the pupil + CHECK(p.lids && p.upper.present && p.lower.present && std::isfinite(p.eyeLid)); + CHECK(lidLineY(p.upper, p.pupilX) + p.margin < p.pupilY && lidLineY(p.lower, p.pupilX) > p.pupilY); } const Shown s = shown(view.packet, Source::Sent); CHECK(s.eyes[0].camera && !s.eyes[1].camera && s.eyes[0].lid > 0.75 && s.eyes[1].squint > 0.3); @@ -460,6 +533,7 @@ int main() { testGeometry(); testViews(); testFile(); + testLidLines(); testReader(); testFresh(); testCameraState(); diff --git a/tools/eyecam/README.md b/tools/eyecam/README.md index 82be228..2334596 100644 --- a/tools/eyecam/README.md +++ b/tools/eyecam/README.md @@ -433,7 +433,7 @@ fd も root もカメラも使わず、ファイルも書かずに、status.json - `~/.local/lib/eyecam/eyecam-rec --replay DIR --out x.csv --preview RUNDIR` で、録画を録画の速さで流しながら同じファイルを `RUNDIR/preview` に書ける(開発用。ライブと同じく tmpfs のフォルダだけ。/dev/shm/… など。終わると消す)。パネルの `--fake-preview --preview-sheet --eyecam-dir RUNDIR --dump-png …` で、その映像で描ける -ファイル(リトルエンディアン、81920 バイト = 20 ページ。使うのは先頭の 64 + 2 × 40064 バイト): +ファイル(リトルエンディアン、81920 バイト = 20 ページ。使うのは先頭の 64 + 2 × 40064 バイトと、そのあとのまぶたの線 2 × 64 バイト): | オフセット | 型 | 名前 | 中身 | |---|---|---|---| @@ -448,8 +448,14 @@ fd も root もカメラも使わず、ファイルも書かずに、status.json | 32 | u64 | `writer_start_ns` | ファイルを作った時刻(`CLOCK_MONOTONIC` の ns) | | 40 | u64 | `t_mono_ns` | 最後に書いた映像の撮影時刻 | | 48 | u32 | `on` | 1 = 映像を出している、0 = 止めた(映像を 0 で上書きしてファイルを消す前に 0 にする) | -| 52 | — | (予約) | 0 | +| 52 | u32 | `lids_at` | まぶたの線の場所(64 + 2 × 40064 = 80192)。0 ならない(この欄ができる前の eyecam-rec) | +| 56 | u32 | `lid_size` | 1 目ぶんのまぶたの線の大きさ(64) | +| 60 | — | (予約) | 0 | | 64 | eye[2] | `eyes` | 左目(64)、右目(64 + 40064)。下の表 | +| `lids_at` | lids[2] | `lids` | 左目、右目(`lids_at` + `lid_size`)のまぶたの線。下の 2 つ目の表 | + +まぶたの線は version 1 で使っていなかった最後のページの残りに足したので、`version` は 1 のまま(知らない読み手は今までどおり読める)。 +読み手は `lids_at` が 0 でなく、絵のあとにあって、ファイルに収まるときだけ読む。 各目(オフセットは目の先頭から): @@ -468,6 +474,25 @@ fd も root もカメラも使わず、ファイルも書かずに、status.json | 48 | — | (予約) | 0 | | 64 | u8[200×200] | `pixels` | 8 bit の灰色、上の行から。2×2 の平均(四捨五入) | +各目のまぶたの線(オフセットは `lids_at` + 目 × `lid_size` から。同じフレームでライブ処理が値を測った線。座標は映像の px): + +| オフセット | 型 | 名前 | 中身 | +|---|---|---|---| +| 0 | f32 | `eye_lid` | そのフレームで送った EyeLid(live の `eye_lid`)。値が有効でなければ NaN | +| 4 | f32 | `upper_x0` | 上まぶたの皮膚の線(`upper_skin_y` の線)を当てはめた最初の列 | +| 8 | f32 | `upper_x1` | 同 最後の列 | +| 12 | f32 | `upper_y0` | 同 `upper_x0` での行。線はこの 3 つの行を通る放物線 | +| 16 | f32 | `upper_ym` | 同 `upper_x0` と `upper_x1` のまん中での行 | +| 20 | f32 | `upper_y1` | 同 `upper_x1` での行 | +| 24 | f32 | `upper_margin` | まぶたのふち(開きを測る線、`upper_y`)が皮膚の線より下にある分 | +| 28 | f32 | `lower_x0` | 下まぶたの線(`lower_y` の線)。並びは上まぶたと同じ(28 最初の列 〜 44 最後の列での行) | +| 48 | — | (予約) | 0 | + +線がないフレーム(見つからない、帯の端に押しつけられた `upper_edge` / `lower_edge`、下の線を前の線で置き換えた +`LOWER_DROP_MAX`)は、その線の 5 つと `upper_margin` が NaN。瞳が見えないフレームでも、最後の瞳の場所で測った線があれば書く。 +線は検出の中で当てはめた放物線をそのまま半分にしただけで、検出のしかたは変わらない(録画を `--replay` で流した CSV がこの欄の前と +バイト単位で同じ) + ### ドリフトの記録(調査用、`driftlog`) 「長くかぶっているとまぶたがずれてくる」を調べるための記録。かぶりの最初に決めて動かさない値(瞳を探す枠の左端 `x_min` は diff --git a/tools/eyecam/src/feat.rs b/tools/eyecam/src/feat.rs index 8032b01..7c7b6ed 100644 --- a/tools/eyecam/src/feat.rs +++ b/tools/eyecam/src/feat.rs @@ -254,6 +254,8 @@ pub struct Pupil { #[derive(Clone, Copy, Debug)] struct Lid { coef: Quad, + /// The first and last of the columns it was fitted over. + cols: (f64, f64), y_at: f64, frac: f64, grad: f64, @@ -271,6 +273,20 @@ impl Lid { fn on_far_edge(&self) -> bool { self.edge_far >= LID_EDGE_SHARE } + + /// Its shape, for the eye preview. + fn curve(&self) -> LidCurve { + LidCurve { coef: self.coef, x0: self.cols.0, x1: self.cols.1 } + } +} + +/// A lid line's shape over the columns it was fitted on (px), only for the panel's eye preview (preview.rs): nothing +/// measured is taken from it. +#[derive(Clone, Copy, Debug)] +pub struct LidCurve { + pub coef: Quad, + pub x0: f64, + pub x1: f64, } /// The per-frame features (NaN = not measured), named as in the prototype's CSV. @@ -296,6 +312,10 @@ pub struct Features { /// The lower lid line found was pushed against its band's far edge: left out, no lower line on this frame (live also /// sets it when it replaces a line found far below the last one, live::LOWER_DROP_MAX). pub lower_edge: bool, + /// The shapes of the upper lid's skin line and the lower lid's line, set with `upper_skin_y` / `lower_y` (for the + /// eye preview only). + pub upper_curve: Option, + pub lower_curve: Option, /// How the search went (not part of the prototype's features). pub diag: Diag, } @@ -321,6 +341,8 @@ impl Default for Features { box_mean: n, upper_edge: false, lower_edge: false, + upper_curve: None, + lower_curve: None, } } } @@ -659,11 +681,13 @@ impl Extractor { f.upper_skin_y = u.y_at; f.upper_y = u.y_at + margin.unwrap_or(MARGIN_OFF); f.upper_frac = u.frac; + f.upper_curve = Some(u.curve()); } f.lower_edge = lower.is_some_and(|l| l.on_far_edge()); if let Some(l) = lower.filter(|l| !l.on_far_edge()) { f.lower_y = l.y_at; f.lower_frac = l.frac; + f.lower_curve = Some(l.curve()); } return (f, None); }; @@ -683,11 +707,13 @@ impl Extractor { f.upper_skin_y = u.y_at; f.upper_y = u.y_at + margin.unwrap_or(MARGIN_OFF); f.upper_frac = u.frac; + f.upper_curve = Some(u.curve()); } f.lower_edge = lower.is_some_and(|l| l.on_far_edge()); if let Some(l) = lower.filter(|l| !l.on_far_edge()) { f.lower_y = l.y_at; f.lower_frac = l.frac; + f.lower_curve = Some(l.curve()); } f.box_mean = self.box_mean(p.cx, p.cy, rn, xmax); (f, Some(p)) @@ -1094,7 +1120,7 @@ impl Extractor { let edge = at_edge as f64 / n_in.max(1) as f64; let at_far = path.iter().zip(&w).filter(|(y, k)| **k && **y + 1 + LID_EDGE_ROWS >= ny).count(); let edge_far = at_far as f64 / n_in.max(1) as f64; - Some(Lid { coef, y_at: coef.at(p.cx), frac, grad, edge, edge_far }) + Some(Lid { coef, cols: (xa as f64, (xb - 1) as f64), y_at: coef.at(p.cx), frac, grad, edge, edge_far }) } /// The calibration's second search for a widened upper lid, on the frame `extract` just ran on (its pupil `p`): @@ -1345,6 +1371,7 @@ mod tests { let (f, p) = ex.extract(&eye_with_step(223), 347.0, None, Some(55.0), None); assert!(p.is_some() && f.ok_pupil && f.upper_edge, "{f:?}"); assert!(f.upper_skin_y.is_nan() && f.upper_y.is_nan() && f.upper_frac.is_nan(), "{f:?}"); + assert!(f.upper_curve.is_none(), "{f:?}"); // The rows of a band as the path sees them: a line on its first two or last two rows is on the edge, one row // further in it is a line. let pupil = p.unwrap(); @@ -1387,10 +1414,15 @@ mod tests { // Lids at R = 55: the lower band is rows 213..304. A step through the middle of it is the line. let (f, p) = ex.extract(&eye_with_lower_step(260), 347.0, None, Some(55.0), None); assert!(p.is_some() && !f.lower_edge && (f.lower_y - 260.0).abs() < 3.0, "{f:?}"); + // The lines' shapes for the eye preview: the same lines, over the iris columns + let (up, low) = (f.upper_curve.expect("upper"), f.lower_curve.expect("lower")); + assert_eq!((up.coef.at(f.pupil_cx), low.coef.at(f.pupil_cx)), (f.upper_skin_y, f.lower_y)); + assert!(up.x0 < f.pupil_cx && f.pupil_cx < up.x1 && (low.x0, low.x1) == (up.x0, up.x1), "{up:?}"); // The only step on the band's last rows: no lower line, with the pupil ... let (f, p) = ex.extract(&eye_with_lower_step(304), 347.0, None, Some(55.0), None); let pupil = p.expect("pupil"); assert!(f.ok_pupil && f.lower_edge && f.lower_y.is_nan() && f.lower_frac.is_nan(), "{f:?}"); + assert!(f.lower_curve.is_none() && f.upper_curve.is_some(), "{f:?}"); assert!(!f.upper_edge && (f.upper_skin_y - 150.0).abs() < 3.0, "the upper line stays: {f:?}"); // ... and without it (measured where the last pupil was). let blank = eye_with_lower_step(304).iter().map(|&v| if v == 12 { 120 } else { v }).collect::>(); diff --git a/tools/eyecam/src/preview.rs b/tools/eyecam/src/preview.rs index 61424fa..b034d62 100644 --- a/tools/eyecam/src/preview.rs +++ b/tools/eyecam/src/preview.rs @@ -8,7 +8,8 @@ //! 0 u32 magic (MAGIC, "EYPV") 4 u32 version (VERSION) 8 u32 header size (HEADER) //! 12 u32 one eye's size (EYE_SIZE) 16 u32 picture width (SIDE) 20 u32 picture height (SIDE) //! 24 u32 seq (odd while being written) 28 u32 writer pid 32 u64 writer start (CLOCK_MONOTONIC ns) -//! 40 u64 last write (CLOCK_MONOTONIC ns) 48 u32 on (1 while on; 0 once turned off) 52..64 reserved +//! 40 u64 last write (CLOCK_MONOTONIC ns) 48 u32 on (1 while on; 0 once turned off) +//! 52 u32 where the lid lines start (LIDS_AT) 56 u32 one eye's lid lines' size (LID_SIZE) 60..64 reserved //! Then each eye (the left eye at HEADER, the right at HEADER + EYE_SIZE; anatomical): //! 0 u64 the frame's capture time (CLOCK_MONOTONIC ns; 0: no picture yet) 8 u64 pictures written of this eye //! 16 u32 flags: bit 0 the pupil was found, bit 1 the eye reads closed @@ -18,7 +19,17 @@ //! 64 SIDE x SIDE bytes: the picture, 8-bit grey, row by row from the top, upright (the left camera's frames, stored //! upside down, are turned the right way first), the temporal (ear) side at small x for both eyes, as the engine //! sees them. The coordinates above are in the same picture. +//! Then, after both eyes (at LIDS_AT, in the last page's space version 1 left unused: a reader that doesn't know them +//! reads the rest as before), each eye's lid lines as the engine found them on the same frame, LID_SIZE bytes each: +//! 0 f32 the EyeLid sent (NaN while the eye's values aren't valid) +//! 4 f32 the upper lid's skin line: its first column 8 ...last column 12 its row at the first column +//! 16 ...halfway between 20 ...at the last column (the line is the parabola through these three; picture px; +//! all NaN without one on this frame) +//! 24 f32 the lid margin's offset below the skin line (picture px; the line the aperture is measured from) +//! 28 f32 the lower lid's line, as the upper one's (28 first column .. 44 row at the last column) +//! 48..64 reserved +use crate::feat::LidCurve; use crate::live::Out; use crate::liveshm::clock_ns; use crate::vision::{H, W}; @@ -38,8 +49,12 @@ pub const SIDE: usize = W / 2; pub const HEADER: usize = 64; const EYE_HEADER: usize = 64; pub const EYE_SIZE: usize = EYE_HEADER + SIDE * SIDE; +/// The lid lines, after both eyes' pictures (in the last page's space that version 1 left unused). +pub const LIDS_AT: usize = HEADER + 2 * EYE_SIZE; +pub const LID_SIZE: usize = 64; /// The whole file, in whole pages. -pub const FILE_SIZE: usize = (HEADER + 2 * EYE_SIZE).div_ceil(4096) * 4096; +pub const FILE_SIZE: usize = (LIDS_AT + 2 * LID_SIZE).div_ceil(4096) * 4096; +const _: () = assert!(FILE_SIZE == 81920, "version 1's size"); /// Pictures a second per eye, at most (the frames between are skipped). pub const RATE: f64 = 15.0; /// How long one `preview on` keeps it on. @@ -52,7 +67,7 @@ const CLOSED: u32 = 2; /// tmpfs's f_type (statfs), where the pictures may go. const TMPFS_MAGIC: i64 = 0x0102_1994; -/// What one picture shows besides the frame: where the pupil was found and where it was looked for. +/// What one picture shows besides the frame: where the pupil was found, where it was looked for, and the lids. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Marks { /// The pupil's centre and radius (picture px), if it was found. @@ -60,6 +75,20 @@ pub struct Marks { /// The search window: left, top, right, bottom (picture px). pub window: [f32; 4], pub closed: bool, + /// The upper lid's skin line and the lower lid's line, if this frame has one: first and last column, and the row + /// at the first, halfway and at the last (picture px). + pub upper: Option<[f32; 5]>, + pub lower: Option<[f32; 5]>, + /// The lid margin's offset below the skin line (picture px; NaN without the upper line). + pub margin: f32, + /// The EyeLid sent (NaN while the values aren't valid). + pub eye_lid: f32, +} + +/// A lid line's ends and middle, halved like the picture. +fn lid_line(c: &LidCurve) -> [f32; 5] { + let xm = (c.x0 + c.x1) / 2.0; + [c.x0, c.x1, c.coef.at(c.x0), c.coef.at(xm), c.coef.at(c.x1)].map(|v| (v / 2.0) as f32) } impl Marks { @@ -67,11 +96,18 @@ impl Marks { pub fn of(o: &Out) -> Self { let f = &o.f; let seen = f.ok_pupil && f.pupil_cx.is_finite() && f.pupil_cy.is_finite() && f.pupil_a.is_finite(); + let upper = f.upper_skin_y.is_finite() && !f.upper_edge; Self { pupil: seen.then(|| ((f.pupil_cx / 2.0) as f32, (f.pupil_cy / 2.0) as f32, (f.pupil_a / 2.0) as f32)), // The left edge as far as the pupil was looked for: the extended search's when it reaches past x_min. window: [o.search_x_min, WINDOW_Y.0, o.xmax, WINDOW_Y.1].map(|v| (v / 2.0) as f32), closed: o.closed, + // The lines this frame's values were measured from: none where it has none (pushed against its band's + // edge, or a lower line the engine replaced with the last one, live::LOWER_DROP_MAX) + upper: f.upper_curve.filter(|_| upper).map(|c| lid_line(&c)), + lower: f.lower_curve.filter(|_| f.lower_y.is_finite() && !f.lower_edge).map(|c| lid_line(&c)), + margin: if upper { ((f.upper_y - f.upper_skin_y) / 2.0) as f32 } else { f32::NAN }, + eye_lid: if o.valid { o.eye_lid as f32 } else { f32::NAN }, } } } @@ -132,7 +168,15 @@ impl PreviewWriter { let mut w = Self { ptr: p.cast(), _file: file, path: path.to_path_buf() }; let start = clock_ns(libc::CLOCK_MONOTONIC); w.write(|b| { - for (at, v) in [(4, VERSION), (8, HEADER as u32), (12, EYE_SIZE as u32), (16, SIDE as u32), (20, SIDE as u32)] { + for (at, v) in [ + (4, VERSION), + (8, HEADER as u32), + (12, EYE_SIZE as u32), + (16, SIDE as u32), + (20, SIDE as u32), + (52, LIDS_AT as u32), + (56, LID_SIZE as u32), + ] { put_u32(b, at, v); } put_u32(b, 28, std::process::id()); @@ -173,6 +217,15 @@ impl PreviewWriter { put_u32(b, at + 20 + i * 4, v.to_bits()); } halve(frame, &mut b[at + EYE_HEADER..at + EYE_SIZE]); + let nan = [f32::NAN; 5]; + let lids = [marks.eye_lid] + .into_iter() + .chain(marks.upper.unwrap_or(nan)) + .chain([marks.margin]) + .chain(marks.lower.unwrap_or(nan)); + for (i, v) in lids.enumerate() { + put_u32(b, LIDS_AT + eye * LID_SIZE + i * 4, v.to_bits()); + } put_u64(b, 40, t_mono_ns.max(u64::from_le_bytes(b[40..48].try_into().unwrap()))); }); } @@ -192,6 +245,7 @@ impl Drop for PreviewWriter { for eye in 0..2 { b[HEADER + eye * EYE_SIZE..HEADER + (eye + 1) * EYE_SIZE].fill(0); } + b[LIDS_AT..LIDS_AT + 2 * LID_SIZE].fill(0); }); unsafe { libc::munmap(self.ptr.cast(), FILE_SIZE) }; let _ = std::fs::remove_file(&self.path); @@ -296,6 +350,8 @@ pub struct Seen { pub flags: [u32; 2], pub marks: [[f32; 7]; 2], pub pictures: [Vec; 2], + /// The lid lines: the EyeLid, the upper line (5), the margin, the lower line (5). + pub lids: [[f32; 12]; 2], } /// Read the file's bytes as a reader would: the header checked (magic, version, sizes), None if it isn't one. @@ -308,6 +364,7 @@ pub fn parse(b: &[u8]) -> Option { || u32_at(8) as usize != HEADER || u32_at(12) as usize != EYE_SIZE || (u32_at(16) as usize, u32_at(20) as usize) != (SIDE, SIDE) + || (u32_at(52) as usize, u32_at(56) as usize) != (LIDS_AT, LID_SIZE) || u32_at(SEQ) % 2 == 1 { return None; @@ -320,6 +377,7 @@ pub fn parse(b: &[u8]) -> Option { flags: [0, 1].map(|e| u32_at(at(e) + 16)), marks: [0, 1].map(|e| std::array::from_fn(|i| f32::from_bits(u32_at(at(e) + 20 + i * 4)))), pictures: [0, 1].map(|e| b[at(e) + EYE_HEADER..at(e) + EYE_SIZE].to_vec()), + lids: [0, 1].map(|e| std::array::from_fn(|i| f32::from_bits(u32_at(LIDS_AT + e * LID_SIZE + i * 4)))), }) } @@ -327,6 +385,7 @@ pub fn parse(b: &[u8]) -> Option { mod tests { use super::*; use crate::feat::Features; + use crate::vision::Quad; fn out(seen: bool, closed: bool) -> Out { let mut o = Out::default(); @@ -398,6 +457,7 @@ mod tests { unsafe { libc::munmap(p, FILE_SIZE) }; let after = parse(&mapped).unwrap(); assert!(!after.on && after.time_ns == [0, 0] && after.pictures.iter().all(|p| p.iter().all(|&v| v == 0))); + assert_eq!(after.lids, [[0.0; 12]; 2]); // Not the preview's: refused let mut bad = mapped.clone(); bad[0] = b'X'; @@ -408,6 +468,49 @@ mod tests { assert!(parse(&mapped[..FILE_SIZE - 1]).is_none()); } + /// `out` with both lid lines (parabolas over columns 180..330 that halve to exact floats), its values valid. + fn out_with_lids() -> Out { + let mut o = out(true, false); + let curve = |c2: f64, y: f64| LidCurve { coef: Quad { c: [c2, 0.0, y], xm: 240.0 }, x0: 180.0, x1: 330.0 }; + o.f.upper_skin_y = 150.0; + o.f.upper_y = 158.0; + o.f.upper_curve = Some(curve(1.0 / 512.0, 150.0)); + o.f.lower_y = 240.0; + o.f.lower_curve = Some(curve(-1.0 / 512.0, 240.0)); + o.valid = true; + o.eye_lid = 0.625; + o + } + + #[test] + fn lid_lines_read_back_halved_and_nan_without_one() { + let path = std::env::temp_dir().join(format!("eyecam-preview-lids-{}", std::process::id())); + let mut w = PreviewWriter::create(&path).unwrap(); + let f = frame(); + // Both lines: the ends and the middle (column 255) of each parabola, halved, and the margin 8 px below + w.picture(0, &f, &Marks::of(&out_with_lids()), 5_000); + // None: the upper line pushed against its band's edge (feat leaves it NaN), the lower one replaced by the last + // one (live::LOWER_DROP_MAX: lower_y is that one's, not this frame's line), the values not valid + let mut none = out_with_lids(); + (none.f.upper_edge, none.f.upper_skin_y, none.f.upper_y) = (true, f64::NAN, f64::NAN); + none.f.lower_edge = true; + none.valid = false; + w.picture(1, &f, &Marks::of(&none), 6_000); + let seen = parse(&std::fs::read(&path).unwrap()).unwrap(); + let (up, mid, low) = (78.515625, 75.2197265625, 82.91015625); + let (up2, mid2, low2) = (116.484375, 119.7802734375, 112.08984375); + assert_eq!(seen.lids[0], [0.625, 90.0, 165.0, up, mid, low, 4.0, 90.0, 165.0, up2, mid2, low2]); + assert!(seen.lids[1].iter().all(|v| v.is_nan()), "{:?}", seen.lids[1]); + // The rest of the eye as before (a reader of version 1 reads the same) + assert_eq!(seen.marks[0], [120.0, 95.0, 10.5, 93.0, 35.0, 173.0, 160.0]); + assert_eq!(&w.read()[52..60], &[(LIDS_AT as u32).to_le_bytes(), (LID_SIZE as u32).to_le_bytes()].concat()[..]); + // A lower line without the pupil is still drawn (the lids are measured where the pupil last was) + let mut lost = out_with_lids(); + lost.f.ok_pupil = false; + assert_eq!(Marks::of(&lost).lower, Some([90.0, 165.0, up2, mid2, low2])); + drop(w); + } + #[test] fn on_only_while_asked_and_fifteen_a_second() { let dir = std::env::temp_dir().join(format!("eyecam-preview-run-{}", std::process::id())); From d9dc6faf0bf3bce30f78cf84d72714a5a3bf167e Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Mon, 26 Oct 2026 20:20:46 +0900 Subject: [PATCH 56/65] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B1=A5=E6=AD=B4:=20?= =?UTF-8?q?=E7=9B=AE=E3=81=AE=E3=83=97=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=81=AE=E3=81=BE=E3=81=B6=E3=81=9F=E3=81=AE=E7=B7=9A=E3=82=92?= =?UTF-8?q?=20Unreleased=20=E3=81=AB=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 4 ++++ CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 5782a68..0e880a1 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -1,5 +1,9 @@ # 更新履歴 +## Unreleased + +- パネル、カメラ映像を出した目のプレビュー: 目のカメラが見つけたまぶたの位置も映像に重ねるようにしました。上まぶたのふちに水色、下まぶたにピンクの線を引き、左下にカメラのまぶたの値(「カメラ まぶた 0.62」)を出します。線が見つからなかったフレームでは、その線を描きません。目の読み取り方は何も変わりません + ## 0.8.2-beta.4 (2026-10-26) 度付きインサートを使っていると、大きく見開いた目が目のカメラで半目に読まれることがまだありました。見開きがもっと安定して出るはずです(更新したらカメラの校正をやり直してね)。「半目の読み直し(AI)」を学習し直して、目の映像に明るい帯や明るいところがあっても、閉じや細めを取り消さないようにしました。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1805dd8..2745c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Panel, eye preview with the camera picture on: the picture now also shows where the eye cameras found your eyelids, a light blue line on the upper lid's edge and a pink one on the lower lid, and the camera's eyelid value at its bottom left ("Camera lid 0.62"). A line is left out on frames where none was found. Nothing about how the eyes are read changes. + ## 0.8.2-beta.4 (2026-10-26) With prescription inserts, a wide-open eye could still be read as half-shut by the eye cameras; widening should now show more reliably (calibrate the cameras again after updating). The "Half-shut check (AI)" was retrained so that a bright band or patch in the eye picture no longer makes it cancel closes and squints. From cb5af1180f071f4dd5232a14c19fba16015ce2ec Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Mon, 26 Oct 2026 20:44:37 +0900 Subject: [PATCH 57/65] =?UTF-8?q?=E7=9B=AE=E3=81=AE=E3=83=97=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC:=20=E3=81=BE=E3=81=B6=E3=81=9F?= =?UTF-8?q?=E3=81=AE=E7=B7=9A=E3=81=A8=E3=80=8C=E3=82=AB=E3=83=A1=E3=83=A9?= =?UTF-8?q?=20=E3=81=BE=E3=81=B6=E3=81=9F=E3=80=8D=E3=82=92=E3=80=81?= =?UTF-8?q?=E7=94=9F=E3=81=AE=E5=80=A4=E4=BB=A5=E5=A4=96=E3=81=A7=E3=81=AF?= =?UTF-8?q?=E3=81=AA=E3=82=81=E3=82=89=E3=81=8B=E3=81=AB=E3=81=97=E3=81=A6?= =?UTF-8?q?=E6=8F=8F=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下まぶたの線が映像ごとに数 px 跳ねて目障りなので、パネルの表示だけ one-euro フィルターをかける (目ごと・線ごとに、両端の列と両端・まん中の行、ふちまでの幅、EyeLid。映像の撮影時刻で。 止まっているとき 1 Hz、1 px/秒ごとに 0.05 Hz 上がる、速さは 1 Hz。EyeLid は 2.5 Hz/単位)。 開発者の録画で下の線のジグザグの約 6 割が消え、まばたきは 1〜2 枚で追いつく。 線が無い映像・0.3 秒より空いた映像・時刻が戻った映像では、その線を今の場所からやり直す。 「生の値」では今までどおり読んだままを描く。eyecam と preview ファイルは変えていない。 --preview-follow SEC で、--dump-png の前にファイルを読み続けてなめらかにした線を描ける。 Co-Authored-By: Claude Opus 5.5 --- README.ja.md | 2 +- README.md | 2 +- panel/README.md | 4 +- panel/src/eye_preview.cpp | 55 +++++++++++++++++++ panel/src/eye_preview.h | 86 ++++++++++++++++++++++++++++++ panel/src/main.cpp | 29 +++++++--- panel/src/panel.cpp | 18 ++++--- panel/src/panel.h | 5 +- panel/tests/eye_preview_test.cpp | 91 ++++++++++++++++++++++++++++++++ 9 files changed, 273 insertions(+), 19 deletions(-) diff --git a/README.ja.md b/README.ja.md index f313dca..16cec0e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -126,7 +126,7 @@ sudo は要りません。全部ホームフォルダ(`~/.local/bin`、`~/.loc | ![詳細のタブ](docs/images/panel-advanced-ja_2026-10-16_23-30-00.png) | | - 左の列には、いつでも今の状態が出ます: 送信中か止めているか、送り先、毎秒の送信回数、目のデータが毎秒何回来ているか(60 未満は赤で「少なめ」。その下に、遅いのが本体か Frame か)、左右のまぶたと視線(生の値と送った値)、設定のエラー。「Track Dominant Eye Only」の設定がオンのあいだは、視線の見出しの行に、Frame がどちらの目で追っているかが出ます(「Frame の設定: 右目だけで追っています」) -- 目のプレビュー: 左の列のいちばん下のボタンで、タブの上にシートを開き、両目の動きをそのまま描きます: まぶた(0.75 がふだんの開き。見開くとまぶたが上がる)、細め(下まぶたが上がる)、瞳孔の大きさ、目の向き。描く値は「送る値」「生の値」(Valve の開き具合と、なめらかにする前の視線)「カメラの値」(目のカメラが読んだ値)から選べます。目の下に、まぶたの値・どこから来たか(「Valve」か「カメラ」)・瞳孔の mm。「鏡で見る」(最初はオン)は鏡に映した向きで、右目が右に出ます(オフでほかの人や正面のアバターから見た向きになり、右目が左に出ます)。「止める」で描くのを止めます。目のカメラを使っているときは「カメラ映像」(うっかり映らないよう最初はオフ。一度オンにすると、パネルを起動し直すか、オフにするか、カメラが止まるまで、開くたびにオンのまま)で、目ごとの今のカメラの映像と、eyecam が見つけた瞳(緑の丸)・瞳を探す範囲(黄色の枠)・上と下のまぶた(水色とピンクの線。そのフレームで見つからなければ出ません)・そのフレームでカメラが読んだまぶたの値・「閉じてる判定」も出します。映像は表示するだけで保存しません([プライバシー](#プライバシー))。アバターの目の形は人によって違うので、動きの確認用です。タブ・✕・ダッシュボードを閉じると閉じます +- 目のプレビュー: 左の列のいちばん下のボタンで、タブの上にシートを開き、両目の動きをそのまま描きます: まぶた(0.75 がふだんの開き。見開くとまぶたが上がる)、細め(下まぶたが上がる)、瞳孔の大きさ、目の向き。描く値は「送る値」「生の値」(Valve の開き具合と、なめらかにする前の視線)「カメラの値」(目のカメラが読んだ値)から選べます。目の下に、まぶたの値・どこから来たか(「Valve」か「カメラ」)・瞳孔の mm。「鏡で見る」(最初はオン)は鏡に映した向きで、右目が右に出ます(オフでほかの人や正面のアバターから見た向きになり、右目が左に出ます)。「止める」で描くのを止めます。目のカメラを使っているときは「カメラ映像」(うっかり映らないよう最初はオフ。一度オンにすると、パネルを起動し直すか、オフにするか、カメラが止まるまで、開くたびにオンのまま)で、目ごとの今のカメラの映像と、eyecam が見つけた瞳(緑の丸)・瞳を探す範囲(黄色の枠)・上と下のまぶた(水色とピンクの線。そのフレームで見つからなければ出ません。「生の値」では読んだまま、ほかではちらつかないようなめらかにします)・そのフレームでカメラが読んだまぶたの値・「閉じてる判定」も出します。映像は表示するだけで保存しません([プライバシー](#プライバシー))。アバターの目の形は人によって違うので、動きの確認用です。タブ・✕・ダッシュボードを閉じると閉じます - 基本: 送信の一時停止、送り先(3 枚のカード: VRChat に直接・VRCFT(LiveLink、おすすめ)・VRCFT(FrameEyeOSC、全部入り)。それぞれほかの人からの見え方(FrameEyeOSC は細めと瞳孔も届くので ◎)と、PC に何が要るかを表示。「VRChat に直接」のときは、カードの下に「目が動かないアバターは、VRCFT(LiveLink か FrameEyeOSC)にすると動きます」と灰色で 1 行。カードの下に「削除予定」の付いた小さな[VRCFT(ETVR)]のボタン。ETVR に送っているときは、その上に「次の版で ETVR はなくなるので、3 つからほかの送り先を選び直して」というオレンジの案内が出ます)、目のカメラ(準備前は「使っていない」と目のカメラタブへの[はじめる →]、準備後は「使う / 使わない」(`camera_lids`)と「左・右 OK」などの短い状態)、言語(日本語 / English。右に[すべて既定に戻す])、SteamVR と一緒に起動(右に[アプリを終了]) - 送り方: 送り先の PC(自動、今送っている PC で固定、または入力。下を参照)とポート。VRChat に直接のときはパラメーター名の頭、EyeTrackingActive の型、Steam Link の名前も送るか、VRChat 標準の目も動かすか、LiveLink・FrameEyeOSC・ETVR のときは PC の VRCFaceTracking で準備すること(FrameEyeOSC は 4 ステップで、モジュールの入手先のアドレス付き。押すと Frame のブラウザーでそのページを開きます)。LiveLink で目のカメラを使っているときは「瞳孔は VRChat に直接送る」(`pupils_to_vrchat`)も。目のカメラを使っていて VRChat に直接か LiveLink のときは「瞳孔の受け取り方」(`pupil_bits`)も。こういう目のカメラがないと使えない行には緑の「カメラ」バッジが付き、カメラを使っていないとき(準備前か「使わない」)は、代わりに点線の 1 行(押すと目のカメラタブ)が出ます - 視線: いちばん上の切り替えで 3 ページ(初めては「動き」、あとは最後に開いたページ。パネルを起動し直しても開きます)。「動き」: スムージングのオン / オフ、なめらかさの弱 / 中 / 強と 3 つの値、見つめている時の遊び。「動く範囲」: 視線の倍率(目が左・右・上・下へどこまで動くか。5〜200 %。目合わせのあとに掛け、目合わせでは変わりません。左右・上下のあいだの鍵を閉じているあいだは、両方いっしょに変わります)、左右の目を別々に動かす。「乱れ対策」: まばたき中は視線を止める、不確かな視線を使わない、一瞬の途切れを消す diff --git a/README.md b/README.md index 67f05e6..0fa50cf 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ To go back, turn "Get test versions" off. Your test version stays installed unti | ![The Advanced tab](docs/images/panel-advanced-en_2026-10-16_23-30-00.png) | | - The left column always shows what frameeyeosc is doing: sending or paused, where it sends to, messages per second, how many samples a second the eye tracker delivers (marked "low" below 60, with a line under it saying whether frameeyeosc or the Frame was the slow one), both eyelids and the gaze (raw and sent), and a config error if there is one. While the "Track Dominant Eye Only" setting is on, the gaze title line says which eye the Frame tracks ("Frame setting: tracking the right eye only"). -- Eye preview: the button at the bottom of the left column opens a sheet over the tabs that draws both eyes as they move: the eyelid (0.75 is relaxed open; widening raises the lid), the squint (the lower lid rises), the pupil's size and where the eye looks, from the values as sent, as read ("Raw": Valve's openness and the gaze before smoothing) or as the eye cameras read them ("Camera"). Under each eye: its eyelid, where it came from ("Valve" or "Camera") and its pupil in mm. "Mirror" (on at first) shows the eyes as in a mirror, your right eye on the right (off: as someone facing you, or your avatar from the front, sees them, your right eye on the left); "Freeze" stops the drawing. With the eye cameras in use, "Camera picture" (off at first, so your eyes never show by accident; once you turn it on, it stays on each time the preview opens until the panel restarts, you turn it off, or the cameras stop) also shows each eye's live camera picture with the pupil eyecam found (green circle), where it looks for it (yellow frame), the upper and lower eyelids it found (blue and pink lines; none on a frame where it found none), the eyelid value the camera read on that frame and "Read as closed". The picture is only shown, never saved (see [Privacy](#privacy)). Avatars' eyes differ, so the drawing is for checking the movement. Any tab, the cross or closing the dashboard closes it. +- Eye preview: the button at the bottom of the left column opens a sheet over the tabs that draws both eyes as they move: the eyelid (0.75 is relaxed open; widening raises the lid), the squint (the lower lid rises), the pupil's size and where the eye looks, from the values as sent, as read ("Raw": Valve's openness and the gaze before smoothing) or as the eye cameras read them ("Camera"). Under each eye: its eyelid, where it came from ("Valve" or "Camera") and its pupil in mm. "Mirror" (on at first) shows the eyes as in a mirror, your right eye on the right (off: as someone facing you, or your avatar from the front, sees them, your right eye on the left); "Freeze" stops the drawing. With the eye cameras in use, "Camera picture" (off at first, so your eyes never show by accident; once you turn it on, it stays on each time the preview opens until the panel restarts, you turn it off, or the cameras stop) also shows each eye's live camera picture with the pupil eyecam found (green circle), where it looks for it (yellow frame), the upper and lower eyelids it found (blue and pink lines; none on a frame where it found none; as read with "Raw", smoothed so they don't flicker otherwise), the eyelid value the camera read on that frame and "Read as closed". The picture is only shown, never saved (see [Privacy](#privacy)). Avatars' eyes differ, so the drawing is for checking the movement. Any tab, the cross or closing the dashboard closes it. - Basic: pause sending, where to send (three cards: VRChat directly, VRCFT (LiveLink), marked recommended, and VRCFT (FrameEyeOSC), marked all-in-one, each saying how others see your eyes (FrameEyeOSC: pupils and squint too) and what is needed on the PC. While VRChat directly is the output, a grey line under them says that if your avatar's eyes don't move, VRCFT (LiveLink or FrameEyeOSC) makes them move. Under them a small "VRCFT (ETVR)" button marked "Deprecated"; while ETVR is the output, an orange notice above it says that ETVR goes away in the next version and to pick one of the three), the eye cameras (before their setup "Not in use" and "Get started →" to the Eye cameras tab; after it "Use / Don't use" (`camera_lids`) and how they are doing, such as "L + R OK"), language (Japanese / English) with "Reset all" at its right, start with SteamVR with "Quit app" at its right. - Output: the target PC (automatic, fixed to the PC it sends to now, or typed: see below) and port. For VRChat directly also the parameter prefix, the EyeTrackingActive type, whether to send Steam Link's parameter names too and whether to move VRChat's own eyes too; for LiveLink, FrameEyeOSC and ETVR what to set up in VRCFaceTracking on the PC instead (for FrameEyeOSC four steps, with the address of the module's download page; pressing it opens the page in the Frame's browser). With LiveLink and the eye cameras, also "Send pupils straight to VRChat" (`pupils_to_vrchat`); with the eye cameras, for VRChat directly and LiveLink, "How the avatar takes pupils" (`pupil_bits`). Rows like these, which work only with the eye cameras, carry a green "Camera" badge; while the cameras aren't in use (not set up, or "Don't use"), one dashed line that opens the Eye cameras tab takes their place. - Gaze: three pages under a switch at the top (the first time "Motion", then the one you opened last, also after the panel restarts). "Motion": smoothing on or off, light / medium / strong presets and the three filter values, deadzone. "Reach": the gaze scale (how far the eyes go left, right, up and down, 5-200 %, applied after the eye fit, which never changes it; while the lock between left and right, or between up and down, is closed, both change together) and moving the eyes separately. "Glitch guard": holding the gaze while blinking, skipping unreliable gaze, removing one-sample glitches. diff --git a/panel/README.md b/panel/README.md index 1b68554..f266911 100644 --- a/panel/README.md +++ b/panel/README.md @@ -38,7 +38,7 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ - 両目(255×170 の箱): アーモンド形の輪郭、白目、虹彩(高さの 0.27)と瞳孔(瞳孔の mm ÷ 11.8 mm × 虹彩の直径。無ければ 4 mm で描く)、虹彩はその目の視線で動く(左右 35°・上下 30° で端まで)。上まぶたは閉じ(0)でいちばん下、ふだんの開き(0.75)で開いた幅の 72%、見開き(1)でいちばん上まで上がる。細めで下まぶたが目の中ほどへ最大 45% 上がる。値が無い目は 0.75 のまま薄く描く。箱の外側の上の角に「左目」「右目」(その目についていく) - 眉: 本体が眉を送っているあいだ(`brow_from_widen` がオンで、眉を受け取る出力。データグラムのフラグ bit 2)だけ、目の上に眉の弧(#8b949e、目の幅の 22〜78%、太さは高さの 4%)。眉 0 で目のすぐ上に休み、眉 1 で目の高さの 30% 上がる(`eye_preview::browLift`)。送る値でだけ描く。生の値とカメラの値では描かない(眉はどこも測っていない。本体が見開きから作る値なので、「読んだ値」は無い)が、目の上の場所は空けたままにして、切り替えても目が動かないようにする。眉の場所の分だけ目のブロックが高くなり、映像を出していて足りないときは目を小さく(縦横の比はそのまま)描く。鏡・止めるも眉に効く。眉を送っていないときは今までとまったく同じ絵 - 目の下のチップ: 「まぶた 0.72」(送る値は VRCFT のまぶた、生の値は Valve の開き具合〔目ごとの倍率の後〕、カメラの値は eyecam のまぶた)、出どころのバッジ(送る値では本体の LidFrom: 0 = ふつうの混ぜ方なら「Valve」、それ以外〔カメラが開いている・閉じている・見開きなど〕は緑の「カメラ」。生の値はいつも Valve、カメラの値はいつもカメラ)、「瞳 6.1 mm」(送る値は送った瞳孔、カメラの値は eyecam の瞳孔、生の値と無いときは「瞳 —」)。描く値: 送る値 = 送ったまぶた・細め・瞳孔・視線、生の値 = Valve の開き具合(倍率の後、1.0 をふだんの開き 0.75 として描く)となめらかにする前の視線、細めと瞳孔は無し、カメラの値 = eyecam のまぶた・細め・瞳孔となめらかにする前の視線(カメラは視線を出さないので)。カメラの値が無い目は値なし - - カメラ映像(オンで、目のカメラを使えるとき): 目ごとに 176×176 の灰色の映像を目の下に。eyecam-rec は両目とも耳側を左(x の小さい側)にして上下を正しく書くので、右の列に描く目の映像を左右反転して、どちらの向きでも耳側をシートの外側にする(映像は描いた目についていく。鏡と正面は互いに鏡像になる)。その上に、見つけた瞳を緑の丸(#5fd38a。見つからなければ無し)、瞳を探す範囲を黄色の点線の枠(#f2d25a)、eyecam がそのフレームで測ったまぶたの線(上まぶたのふちを水色 #6cc4ff、下まぶたをピンク #ff8fc8。放物線を 16 本の線分で、暗い縁取りの上に。その線が無いフレームや、線の無い古い eyecam-rec のファイルでは描かない)、eyecam が閉じていると読んでいるときは左上に「閉じてる判定」、左下にそのフレームで eyecam が送ったまぶたの値(「カメラ まぶた 0.62」。値が有効でなければ無し)。映像が無い・0.5 秒より古いときは「映像を待っています」。映像はその描画の間だけメモリの上で RGB にして描き、どこにも書かない + - カメラ映像(オンで、目のカメラを使えるとき): 目ごとに 176×176 の灰色の映像を目の下に。eyecam-rec は両目とも耳側を左(x の小さい側)にして上下を正しく書くので、右の列に描く目の映像を左右反転して、どちらの向きでも耳側をシートの外側にする(映像は描いた目についていく。鏡と正面は互いに鏡像になる)。その上に、見つけた瞳を緑の丸(#5fd38a。見つからなければ無し)、瞳を探す範囲を黄色の点線の枠(#f2d25a)、eyecam がそのフレームで測ったまぶたの線(上まぶたのふちを水色 #6cc4ff、下まぶたをピンク #ff8fc8。放物線を 16 本の線分で、暗い縁取りの上に。その線が無いフレームや、線の無い古い eyecam-rec のファイルでは描かない。「生の値」では読んだまま、ほかでは目ごとに映像の撮影時刻で one-euro フィルター〔止まっているとき 1 Hz、線が 1 px/秒動くごとに 0.05 Hz 上がる。速さは 1 Hz で〕をかけて描く。下まぶたの線は 1 枚だけ数 px 跳ねることが多く、開発者の録画でそのジグザグの約 6 割が消え、まばたきは 1〜2 枚で追いつく。線が無い映像・0.3 秒より空いた映像・時刻が戻った映像でその線をやり直す〔前の場所から流れてこない〕。`eye_preview::LidSmoother`)、eyecam が閉じていると読んでいるときは左上に「閉じてる判定」、左下にそのフレームで eyecam が送ったまぶたの値(「カメラ まぶた 0.62」。値が有効でなければ無し。線と同じく「生の値」以外では同じフィルター〔速さで 2.5 Hz/単位〕をかける)。映像が無い・0.5 秒より古いときは「映像を待っています」。映像はその描画の間だけメモリの上で RGB にして描き、どこにも書かない - 使えないとき(eyecam-rec が動いていない・準備前・`camera_lids` がオフ・status.json に `preview` が無い古い eyecam-rec・`preview on` を断られた)は「カメラ映像」を押せなくし、スイッチの下に理由を 1 行(「目のカメラが動いていません」など)。最初の 3 つでは「カメラの値」も押せず、送る値で描く。目はカメラなしでも Valve の値で動く。映像が無いときは、目とチップを空いたところの真ん中に。使えなくなったら「カメラ映像」はオフに戻し、また使えるようになってももう一度押すまで映像は出さない。`preview on` を断られた(2 秒で返事が無いときも)ら理由を 5 秒(`kRefusedSec`)出してから、また押せるようにする。本体のサンプルが無い目には出どころのバッジを出さない - いちばん下に「視線 左右 +5°・上下 −3°」(送る値は送った視線、ほかはなめらかにする前の視線。正面から見た向きでも本人から見た向き: + が右・上)と、映像を出しているときは「映像は表示するだけで保存しません。緑の丸が見つけた瞳、黄色の枠が瞳を探す範囲、水色とピンクの線が上と下のまぶた」、出していないときは「アバターの目の形は人によって違います。動きの確認用です」。本体のサンプルが 1 秒来なければ「目のデータがありません」 - 値の受け取り方: シートが見えているあいだ(ダッシュボードが開いていて)だけ、視線の点のソケット `gaze-dots.sock` を開く(点のスイッチがオフなら点は出さない)。本体はそのソケットがあれば(1 秒に 1 回見る)サンプルごとに版 3 のデータグラム(160 バイト: 送った視線・なめらかにする前の視線と、目ごとに送ったまぶた・Valve の開き具合〔倍率の前と後〕・カメラのまぶた・送った細めと瞳孔・カメラの細めと瞳孔・LidFrom・カメラの閉じ判定・送った眉〔u16、0.8.0 から。もとは 0 の予備のバイト〕、最後に視線の倍率を掛ける前の視線。`src/dots.rs`)を送る。眉はフラグの bit 2 が立っているときだけ読む(版と大きさはそのまま: 0.7.10 までのパネルは眉を読まずにほかは同じに読み、0.7.10 までの本体のデータグラムは眉なしとして読む)。版 2(136 バイト、0.7.7 までの本体)も読む(倍率の前の視線は送った視線と同じとみなし、輪は出さない)。ほかの版は無視する @@ -237,7 +237,7 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - `--update-live` を付けると本物の更新の仕組みを動かす: 最初に確認し、`--click` のあとは始まった確認や更新が終わるまで待ってから描く。更新は本当に行われるので、偽の GitHub(`FRAME_UPDATE_API_URL`・`FRAME_UPDATE_ALLOW_INSECURE=1`)と別の `HOME` で試す - 診断は `--diag` で開いて描く(`--fake` とも、本物のファイルとも。本物なら SteamOS の版と道具の SHA-256 も本物を読む)。`--fake` の SteamOS と道具は開発者のヘッドセットの値。`--fake-eyecam` の `:prox=V`(近接センサー)・`:blocks=N`(最後に探す前に変化したブロック)・`:lasterror`(eyecam-rec の最後のエラー、3 分前)・`:camfps=N`(カメラの速さ `cam_fps`。作り物の校正のメッセージの枚数〔N fps での 5.4 秒と要る数〕も合わせ、60 未満なら eyecam-rec と同じ「カメラの映像が毎秒 N 枚しか届いていない」も付く)でその行を変えられる。最後に探したときの様子は状態から(映像があれば 8 スロット・両目、`search=…` ならそこで止まったところ)、最後の校正は `setup`・`failed=…`・`calib-error` から作る - 詳細タブの小タブは `--adv-page version|trouble|tools|files` で選ぶ(既定は version。`panel.json` は読まない)。まぶたタブの小タブは `--lids-page look|blinks|fine`(既定は look。`--lid-marks` は fine と同じ。`panel.json` は読まない)。視線タブの小タブは `--gaze-page motion|reach|glitch`(既定は motion。`panel.json` は読まない)。`--adv-scroll px` でスクロールして描く(中身の範囲に収めるので、大きな値でいちばん下) -- 目のプレビューは `--preview-sheet [SOURCE[:nomirror][:nocamera][:frozen]]`(SOURCE は sent / raw / camera。既定は sent・鏡・映像あり)で開いて描く。`--fake-preview [ok|noeyecam|nopicture|closed|nodata]` は作り物の値と灰色の絵(目ではない。本物の目の映像はテストにも資料にも使わない)で、目のカメラは準備済み(`--fake-eyecam` があればそちら。`:nopreview` で映像を出せない古い eyecam-rec): 全部ある・eyecam-rec が動いていない・映像がまだ・左目を閉じている・本体のサンプルが無い。`--fake` を含む。`--eyecam-dir DIR` の `preview` ファイルがあれば絵はそこから読む(`~/.local/lib/eyecam/eyecam-rec --replay … --preview DIR` で録画を流しながら向きを確かめる用。その PNG は外に出さない)。`--preview-bench N` で N 回描いた時間を出す +- 目のプレビューは `--preview-sheet [SOURCE[:nomirror][:nocamera][:frozen]]`(SOURCE は sent / raw / camera。既定は sent・鏡・映像あり)で開いて描く。`--fake-preview [ok|noeyecam|nopicture|closed|nodata]` は作り物の値と灰色の絵(目ではない。本物の目の映像はテストにも資料にも使わない)で、目のカメラは準備済み(`--fake-eyecam` があればそちら。`:nopreview` で映像を出せない古い eyecam-rec): 全部ある・eyecam-rec が動いていない・映像がまだ・左目を閉じている・本体のサンプルが無い。`--fake` を含む。`--eyecam-dir DIR` の `preview` ファイルがあれば絵はそこから読む(`~/.local/lib/eyecam/eyecam-rec --replay … --preview DIR` で録画を流しながら向きを確かめる用。その PNG は外に出さない)。`--preview-bench N` で N 回描いた時間を出す。`--preview-follow SEC` は描く前に SEC 秒そのファイルを読み続け、まぶたの線をライブと同じようになめらかにしてから描く - ほかの Frame アプリの一覧は `--apps-sheet list` で、その上の確認は `--apps-sheet confirm`(入っていない最初のアプリ)・`confirm:呼び名`・`menu`(インストーラーのメニュー)で開いて描く。`--fake` か `--fake-apps-state keyboard=missing,mic=running,perf=missing,aux=installed`(`missing` / `installed` / `running` / `busy` = Konsole が開いている。`menu=busy` でメニュー)なら同梱の一覧と作り物の状態(既定は keyboard・perf が入っていない、mic が動作中、aux が入っている)、どちらも無ければ今の入り具合と前に取った一覧。`--dump-png` からは取りに行かないし Konsole も開かない(`--click` の[Konsole で開く]はコマンドを出すだけ)。`--fake-apps-launch busy|nodisplay|nokonsole|failed` で開けなかったときの確認、`--fake-apps-extra N` でアイコンの無い作り物のアプリを N 個足す(スクロールの確認) - 記録は `--reports-dir DIR`(既定は本物の記録のフォルダ)から読む。`--record 名前|latest` でその記録、`--records-all` ですべての記録の一覧を開いて描く。`--fake-records` は作り物の 10 件(`--record N` で N 番目、0 がいちばん新しい)。`--fake-saved` で目合わせ・校正の失敗の画面に「記録したよ」の帯(`--fake-fit failed-…` か `--fake-eyecam calib-error…` と一緒に) - `--report-make 種類 始め 終わり 結果 理由`(確かめるためのもの): その時間のジャーナルと Valve のログから、パネルが走り終わったあとに書くのと同じ記録を `--reports-dir` に書き、フォルダを出す(例: `--report-make fit "2026-10-06 20:45:09" "2026-10-06 20:45:17" failed "…"`。条件と診断コードは空) diff --git a/panel/src/eye_preview.cpp b/panel/src/eye_preview.cpp index 93a0734..761c966 100644 --- a/panel/src/eye_preview.cpp +++ b/panel/src/eye_preview.cpp @@ -119,6 +119,59 @@ double lidLineY(const LidLine& line, double x) { return line.y[0] * (1 - s) * (1 - 2 * s) + line.y[1] * 4 * s * (1 - s) + line.y[2] * s * (2 * s - 1); } +double OneEuro::step(double value, double t) { + if (!std::isfinite(value) || !std::isfinite(t)) { + started_ = false; + return value; + } + const double dt = t - t_; + if (!started_ || dt <= 0 || dt > kLidResetSec) { + started_ = true; + x_ = value; + dx_ = 0.0; + t_ = t; + return value; + } + t_ = t; + // (the share of the way a first-order low-pass at `cutoff` moves in dt) + const auto share = [dt](double cutoff) { return 1.0 / (1.0 + 1.0 / (2 * M_PI * cutoff * dt)); }; + dx_ += share(dCutoff_) * ((value - x_) / dt - dx_); + x_ += share(minCutoff_ + beta_ * std::fabs(dx_)) * (value - x_); + return x_; +} + +void LidSmoother::take(Pictures& pictures) { + for (int e = 0; e < 2; ++e) { + Picture& p = pictures.eyes[e]; + Eye& eye = eyes_[e]; + if (!p.present || (p.count == eye.count && p.timeNs == eye.timeNs)) continue; + eye.count = p.count; + eye.timeNs = p.timeNs; + const double t = p.timeNs * 1e-9; + // Each line as a whole: none on this picture starts all of its filters over + const auto line = [t](const LidLine& in, OneEuro* filters) { + LidLine out = in; + if (!in.present) { + for (int i = 0; i < 5; ++i) filters[i].reset(); + return out; + } + out.x0 = filters[0].step(in.x0, t); + out.x1 = filters[1].step(in.x1, t); + for (int i = 0; i < 3; ++i) out.y[i] = filters[2 + i].step(in.y[i], t); + return out; + }; + p.smoothed.upper = line(p.upper, eye.upper); + p.smoothed.lower = line(p.lower, eye.lower); + if (!p.upper.present) eye.margin.reset(); + p.smoothed.margin = p.upper.present ? eye.margin.step(p.margin, t) : p.margin; + p.smoothed.eyeLid = eye.eyeLid.step(p.eyeLid, t); + } +} + +void LidSmoother::reset() { + for (Eye& eye : eyes_) eye = Eye(); +} + Shown shown(const gaze_dots::Packet& packet, Source source, const CameraLids& lids) { Shown s; s.valid = true; @@ -260,6 +313,7 @@ bool parsePictures(const uint8_t* data, size_t size, Pictures& out, std::string& p.margin = f32At(data, l + 24); p.lower = lidLineAt(data, l + 28); } + p.smoothed = {p.upper, p.margin, p.lower, p.eyeLid}; } out = std::move(pictures); return true; @@ -521,6 +575,7 @@ void fake(View& view, uint64_t nowNs) { std::copy(upper, upper + 3, pic.upper.y); std::copy(lower, lower + 3, pic.lower.y); pic.margin = 4; + pic.smoothed = {pic.upper, pic.margin, pic.lower, pic.eyeLid}; } view.hasPictures = true; view.nowNs = nowNs; diff --git a/panel/src/eye_preview.h b/panel/src/eye_preview.h index 48b213f..40aaf55 100644 --- a/panel/src/eye_preview.h +++ b/panel/src/eye_preview.h @@ -195,6 +195,14 @@ struct LidLine { */ double lidLineY(const LidLine& line, double x); +/** A picture's lid lines and EyeLid, smoothed over the pictures for the drawing (LidSmoother). */ +struct SmoothedLids { + LidLine upper; + double margin = NAN; + LidLine lower; + double eyeLid = NAN; +}; + /** One eye's picture, as eyecam-rec wrote it: upright, the temporal (ear) side at small x. */ struct Picture { bool present = false; ///< a picture was written (its time isn't 0) @@ -213,6 +221,7 @@ struct Picture { double margin = NAN; ///< ...and its margin this far below it (picture px; the opening is measured from it) LidLine lower; ///< the lower lid's line double eyeLid = NAN; ///< the EyeLid eyecam-rec sent for this frame (NaN: its values weren't valid) + SmoothedLids smoothed; ///< the same smoothed over the pictures (as read until a LidSmoother takes them) }; /** Both pictures. */ @@ -285,6 +294,83 @@ class PictureReader { std::string badWhy_; ///< ...why }; +/** + * The lid lines' smoothing (picture px; in Hz): calm at rest (about 0.16 s), the cutoff up 0.05 Hz for each px a second + * the line moves, so a blink is followed within a picture or two. Tuned on the developer's recordings (the lower line + * jumps a few px for single pictures; about 60% of its picture-to-picture zigzag goes). + */ +constexpr double kLidMinCutoffHz = 1.0; +constexpr double kLidBeta = 0.05; +constexpr double kLidDCutoffHz = 1.0; +/** The same for the EyeLid on the picture (0..1: about 50 px of lid to the unit). */ +constexpr double kEyeLidBeta = 2.5; +/** A gap between pictures longer than this (s) starts the smoothing over (no line swept in from an old place). */ +constexpr double kLidResetSec = 0.3; + +/** + * The one-euro filter (Casiez, Roussel and Vogel, 2012) on the pictures' capture times: a low-pass whose cutoff rises + * with the speed, so a value that jitters in place is damped and one that moves fast (a blink) is followed. No value + * (NaN), a gap of more than kLidResetSec or a time not after the last starts it over at the new value. + */ +class OneEuro { +public: + /** + * @param minCutoff the cutoff at rest (Hz) + * @param beta how much the cutoff rises with the speed (Hz per unit a second) + * @param dCutoff the cutoff of the speed itself (Hz) + */ + explicit OneEuro(double minCutoff = kLidMinCutoffHz, double beta = kLidBeta, double dCutoff = kLidDCutoffHz) + : minCutoff_(minCutoff), beta_(beta), dCutoff_(dCutoff) {} + + /** + * The next value. + * @param value the value (NaN: none, and the filter starts over) + * @param t its time (s) + * @return the smoothed value (the value itself when the filter starts over) + */ + double step(double value, double t); + + /** Start over at the next value. */ + void reset() { started_ = false; } + +private: + double minCutoff_; + double beta_; + double dCutoff_; + bool started_ = false; + double x_ = 0.0; ///< the smoothed value... + double dx_ = 0.0; ///< ...its smoothed speed (a second) + double t_ = 0.0; ///< ...at this time +}; + +/** + * Smooths each eye's lid lines and EyeLid over the pictures read (every source but Raw draws them smoothed). Each line + * starts over where it has none on a picture, so one that comes back starts where it is. + */ +class LidSmoother { +public: + /** + * Fill each new picture's `smoothed` (one already taken, by its count and time, is left as it is). + * @param pictures as just read + */ + void take(Pictures& pictures); + + /** Start over (the pictures stopped). */ + void reset(); + +private: + /** One eye's filters: each line's first and last column and its rows there and halfway, the margin, the EyeLid. */ + struct Eye { + uint64_t count = 0; + uint64_t timeNs = 0; + OneEuro upper[5]; + OneEuro lower[5]; + OneEuro margin; + OneEuro eyeLid {kLidMinCutoffHz, kEyeLidBeta, kLidDCutoffHz}; + }; + Eye eyes_[2]; +}; + /** * Whether a picture is new enough to show. * @param picture the picture diff --git a/panel/src/main.cpp b/panel/src/main.cpp index b92896d..c327024 100644 --- a/panel/src/main.cpp +++ b/panel/src/main.cpp @@ -112,6 +112,7 @@ struct Options { std::string fakePreview; ///< --fake-preview: made-up preview values and pictures ("ok" or a state) double fakePreviewBrows = NAN; ///< --fake-preview-brows: brows sent at this level in them (NaN: none) int previewBench = 0; ///< --preview-bench N: time N draws of the panel with the eye preview open + double previewFollow = 0.0; ///< --preview-follow SEC: read the preview file this long first (smoothed lid lines) std::string fakeAppsState; ///< --fake-apps-state: made-up states of the other apps (KEY=STATE,...) std::string fakeAppsLaunch; ///< --fake-apps-launch: why Konsole didn't open (on the confirmation) int fakeAppsExtra = 0; ///< --fake-apps-extra N: made-up apps without an icon added to the list @@ -292,6 +293,8 @@ void printUsage() { " --fake-preview-brows LEVEL Brows sent in the made-up preview values, both at LEVEL (0 at rest ..\n" " 1 raised all the way). Implies --fake-preview (ok unless given) and --fake-brows\n" " --preview-bench N Time N draws of the panel with the eye preview open (with --preview-sheet)\n" + " --preview-follow SEC With --preview-sheet: read eyecam-rec's preview file for SEC seconds before drawing,\n" + " so the lid lines are smoothed over those pictures as live (eyecam-rec --replay --preview)\n" " --changelog-dir DIR Read CHANGELOG.md / CHANGELOG.ja.md from DIR instead of next to the binary,\n" " the checkout (panel/build) or ~/.local/share/frameeyeosc\n" " --preview-quit Show \"press again to quit\"\n" @@ -858,6 +861,8 @@ bool parseOptions(int argc, char** argv, Options& options) { options.fake = options.fakeBrows = true; } else if (arg == "--preview-bench" && hasNext) { options.previewBench = std::max(0, std::min(10000, std::atoi(argv[++i]))); + } else if (arg == "--preview-follow" && hasNext) { + options.previewFollow = std::max(0.0, std::min(60.0, std::atof(argv[++i]))); } else if (arg == "--apps-sheet" && hasNext) { options.appsSheet = argv[++i]; const std::string& sheet = options.appsSheet; @@ -2407,19 +2412,26 @@ int runDumpPng(const Options& options) { panel.setPreviewOptions(source, mirror, camera, frozen); // The pictures from eyecam-rec's preview file when it is there (eyecam-rec --replay --preview DIR, with // --eyecam-dir DIR); a file of another kind says why + // (--preview-follow: every picture of the next seconds, the lid lines smoothed over them as live) eye_preview::PictureReader reader; + eye_preview::LidSmoother smoother; std::string why; const std::string path = model.eyecamDir + "/" + eye_preview::kFileName; - switch (reader.read(path, model.preview.pictures, why)) { - case eye_preview::ReadResult::Read: + const double followUntil = nowSeconds() + options.previewFollow; + for (bool said = false;;) { + const eye_preview::ReadResult read = reader.read(path, model.preview.pictures, why); + if (read == eye_preview::ReadResult::Read) { + smoother.take(model.preview.pictures); model.preview.hasPictures = true; model.preview.nowNs = eye_preview::monotonicNs(); - std::fprintf(stderr, "[preview] pictures from %s\n", path.c_str()); - break; - case eye_preview::ReadResult::Invalid: + if (!said) std::fprintf(stderr, "[preview] pictures from %s\n", path.c_str()); + said = true; + } else if (read == eye_preview::ReadResult::Invalid) { std::fprintf(stderr, "[preview] %s: %s\n", path.c_str(), why.c_str()); break; - default: break; + } + if (nowSeconds() >= followUntil) break; + ::usleep(5000); } } if (options.previewQuit) panel.armQuitForPreview(); @@ -3893,6 +3905,7 @@ int runOverlay(const Options& options) { // pictures from its preview file, asked for with "preview on" every second while they show (a control socket // connection of their own, so the eye cameras tab's replies stay its own) eye_preview::PictureReader previewReader; + eye_preview::LidSmoother previewSmoother; eye_preview::Asker previewAsker; eyecam::Control previewControl; bool previewShown = false; // the sheet showed at the last loop @@ -4474,6 +4487,7 @@ int runOverlay(const Options& options) { if (!previewing) { model.preview = eye_preview::View(); previewReader.close(); + previewSmoother.reset(); previewLogged.clear(); } } @@ -4516,6 +4530,8 @@ int runOverlay(const Options& options) { const std::string path = model.eyecamDir + "/" + eye_preview::kFileName; const eye_preview::ReadResult read = previewReader.read(path, model.preview.pictures, why); if (read == eye_preview::ReadResult::Read) { + // (the lid lines smoothed over the pictures, for every source but Raw) + previewSmoother.take(model.preview.pictures); if (!model.preview.hasPictures) std::fprintf(stderr, "[preview] pictures from %s\n", path.c_str()); model.preview.hasPictures = true; previewPacer.changed(); @@ -4530,6 +4546,7 @@ int runOverlay(const Options& options) { model.preview.hasPictures = false; model.preview.pictures = eye_preview::Pictures(); previewReader.close(); + previewSmoother.reset(); dirty = true; } if (previewPacer.due(now)) dirty = true; diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index 1cd0b8e..0f1b274 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -7506,7 +7506,7 @@ void EyePanel::drawPreviewEye(const Pen& pen, const eye_preview::EyeShown& eye, } void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_preview::Picture& picture, bool fresh, - double x, double y, double side, bool flip) { + double x, double y, double side, bool flip, bool smooth) { cairo_t* cr = pen.cr; const double r = 12; fillRounded(pen, x, y, side, side, r, kBg); @@ -7557,7 +7557,11 @@ void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_pre cairo_set_dash(cr, nullptr, 0, 0); } // The lid lines eyecam-rec measured on this frame (none where it found none): the upper lid's edge (its margin, - // which the opening is measured from) and the lower lid's, each on a dark edge so it shows on bright skin too + // which the opening is measured from) and the lower lid's, each on a dark edge so it shows on bright skin too. + // Smoothed over the pictures but in Raw (the lower one jumps a few px from picture to picture) + const eye_preview::SmoothedLids lids = smooth ? picture.smoothed + : eye_preview::SmoothedLids {picture.upper, picture.margin, + picture.lower, picture.eyeLid}; const auto lidLine = [&](const eye_preview::LidLine& line, double below, Color color) { if (!line.present) return; const int steps = 16; @@ -7577,8 +7581,8 @@ void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_pre cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT); }; if (picture.lids) { - lidLine(picture.upper, std::isfinite(picture.margin) ? picture.margin : 0.0, kPreviewLidUpper); - lidLine(picture.lower, 0.0, kPreviewLidLower); + lidLine(lids.upper, std::isfinite(lids.margin) ? lids.margin : 0.0, kPreviewLidUpper); + lidLine(lids.lower, 0.0, kPreviewLidLower); } if (picture.pupil) drawRing(cr, px(picture.pupilX), py(picture.pupilY), picture.pupilR * scale, 2.5, kPreviewPupil); const auto badge = [&](double by, const char* text) { @@ -7591,9 +7595,9 @@ void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_pre }; if (picture.closed) badge(y + 8, t.previewClosed); // The EyeLid eyecam-rec sent for this very frame, under its lines - if (picture.lids && std::isfinite(picture.eyeLid)) { + if (picture.lids && std::isfinite(lids.eyeLid)) { char lid[64]; - std::snprintf(lid, sizeof(lid), t.previewPictureLidFormat, picture.eyeLid); + std::snprintf(lid, sizeof(lid), t.previewPictureLidFormat, lids.eyeLid); badge(y + side - 32, lid); } cairo_restore(cr); @@ -7761,7 +7765,7 @@ void EyePanel::drawPreview(const Pen& pen, const UiText& t, const PanelModel& m) const eye_preview::Picture& picture = pv.pictures.eyes[eye]; const bool fresh = pv.hasPictures && eye_preview::pictureFresh(picture, pv.nowNs); drawPreviewPicture(pen, t, picture, fresh, cx - pictureSide / 2, chipsY + chipsH + 10, pictureSide, - eye_preview::pictureFlipped(eye, previewMirror_)); + eye_preview::pictureFlipped(eye, previewMirror_), source != Source::Raw); } } diff --git a/panel/src/panel.h b/panel/src/panel.h index 1ea66f3..02c832f 100644 --- a/panel/src/panel.h +++ b/panel/src/panel.h @@ -833,7 +833,7 @@ class EyePanel { /** * One eye's camera picture, upright with its temporal side toward the sheet's outside, the search window - * and the found pupil on it, and "Read as closed"; "waiting" while there is none. + * and the found pupil on it, the lid lines and their EyeLid, and "Read as closed"; "waiting" while there is none. * @param pen drawing tools * @param t texts * @param picture the picture @@ -842,9 +842,10 @@ class EyePanel { * @param y top * @param side its size (px) * @param flip mirrored left to right + * @param smooth the lid lines and EyeLid smoothed over the pictures (else as read: Raw) */ void drawPreviewPicture(const Pen& pen, const UiText& t, const eye_preview::Picture& picture, bool fresh, - double x, double y, double side, bool flip); + double x, double y, double side, bool flip, bool smooth); /** * A switch as a pill: a check, bold and the accent while on. diff --git a/panel/tests/eye_preview_test.cpp b/panel/tests/eye_preview_test.cpp index dac6205..de1f460 100644 --- a/panel/tests/eye_preview_test.cpp +++ b/panel/tests/eye_preview_test.cpp @@ -326,6 +326,96 @@ void testLidLines() { std::memcpy(bad.data() + 52, &at, 4); CHECK(parsePictures(bad.data(), bad.size(), read, why) && !read.eyes[0].lids && read.eyes[0].pupil); } + // As read, the smoothed lines are the lines themselves (until a LidSmoother takes them) + CHECK(parsePictures(bytes.data(), bytes.size(), read, why)); + CHECK(read.eyes[0].smoothed.upper.present && near(read.eyes[0].smoothed.upper.y[1], 75.25) && + !read.eyes[0].smoothed.lower.present && near(read.eyes[0].smoothed.eyeLid, 0.625)); +} + +void testSmoothing() { + const double dt = 1.0 / 15; // the preview's pictures + // Jitter in place (±2 px from picture to picture) is damped to under a quarter... + OneEuro jitter; + double low = 1e9; + double high = -1e9; + for (int i = 0; i < 60; ++i) { + const double v = jitter.step(100 + (i % 2 == 0 ? -2 : 2), i * dt); + if (i >= 30) { + low = std::min(low, v); + high = std::max(high, v); + } + } + CHECK(high - low < 1.0 && near(low + high, 200, 0.5)); + // ...a single picture's jump of 7 px to about half of it... + OneEuro spike; + for (int i = 0; i < 20; ++i) spike.step(130, i * dt); + CHECK(spike.step(123, 20 * dt) > 126); + // ...while a step (a blink: 30 px) is followed: most of the way on its first picture, all but 1 px by the third + OneEuro step; + for (int i = 0; i < 20; ++i) step.step(100, i * dt); + const double first = step.step(130, 20 * dt); + step.step(130, 21 * dt); + CHECK(first > 120 && step.step(130, 22 * dt) > 129); + // No value starts it over: the next value is taken as it is, not swept in from the old one + CHECK(std::isnan(step.step(NAN, 23 * dt)) && near(step.step(60, 24 * dt), 60)); + // So does a gap of more than kLidResetSec, and a time going back + CHECK(near(step.step(90, 24 * dt + kLidResetSec + 0.01), 90)); + CHECK(near(step.step(70, 1.0), 70)); + // The EyeLid's (0..1): a blink to closed follows within two pictures + OneEuro lid(kLidMinCutoffHz, kEyeLidBeta, kLidDCutoffHz); + for (int i = 0; i < 20; ++i) lid.step(0.75, i * dt); + const double closing = lid.step(0.0, 20 * dt); + CHECK(closing < 0.25 && lid.step(0.0, 21 * dt) < 0.05); + + // The smoother: by picture (count and time), each line on its own, the EyeLid too + LidSmoother smoother; + Pictures pictures = madeUp(200); + for (Picture& p : pictures.eyes) { + p.lids = true; + p.eyeLid = 0.75; + p.margin = 4; + for (LidLine* line : {&p.upper, &p.lower}) { + line->present = true; + line->x0 = 90; + line->x1 = 165; + } + } + const auto lowerAt = [&](uint64_t timeNs, double row) { + for (Picture& p : pictures.eyes) { + p.timeNs = timeNs; + ++p.count; + const double upper[3] = {80, 75, 82}; + const double lower[3] = {row, row + 4, row - 2}; + std::copy(upper, upper + 3, p.upper.y); + std::copy(lower, lower + 3, p.lower.y); + p.smoothed = {}; + } + smoother.take(pictures); + }; + const uint64_t ns = 66666667; // 1/15 s + for (int i = 0; i < 20; ++i) lowerAt(1000000000ull + i * ns, i % 2 == 0 ? 114 : 118); + const SmoothedLids& s = pictures.eyes[0].smoothed; + CHECK(s.lower.present && s.lower.y[1] > 119 && s.lower.y[1] < 121.5 && near(s.lower.x0, 90) && near(s.margin, 4)); + CHECK(near(s.upper.y[1], 75) && near(s.eyeLid, 0.75) && pictures.eyes[1].smoothed.lower.present); + // The same picture again (read twice): left as it is, not smoothed once more + pictures.eyes[0].smoothed.lower.y[1] = -1; + smoother.take(pictures); + CHECK(near(pictures.eyes[0].smoothed.lower.y[1], -1)); + // The lower line gone on a picture: none drawn, and back 20 px lower it starts where it is + for (Picture& p : pictures.eyes) { + p.lower.present = false; + p.timeNs += ns; + ++p.count; + } + smoother.take(pictures); + CHECK(!pictures.eyes[0].smoothed.lower.present && pictures.eyes[0].smoothed.upper.present); + for (Picture& p : pictures.eyes) p.lower.present = true; + lowerAt(1000000000ull + 21 * ns, 136); + CHECK(near(pictures.eyes[0].smoothed.lower.y[0], 136) && near(pictures.eyes[0].smoothed.lower.y[1], 140)); + // Started over (the pictures stopped): the next picture as it is + smoother.reset(); + lowerAt(1000000000ull + 22 * ns, 100); + CHECK(near(pictures.eyes[1].smoothed.lower.y[0], 100)); } /** @@ -534,6 +624,7 @@ int main() { testViews(); testFile(); testLidLines(); + testSmoothing(); testReader(); testFresh(); testCameraState(); From 37f480ff01c5a67a28507dffec706ca0722cabe4 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Mon, 26 Oct 2026 20:44:54 +0900 Subject: [PATCH 58/65] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B1=A5=E6=AD=B4:=20?= =?UTF-8?q?=E7=9B=AE=E3=81=AE=E3=83=97=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=81=AE=E3=81=BE=E3=81=B6=E3=81=9F=E3=81=AE=E7=B7=9A=E3=82=92?= =?UTF-8?q?=E3=81=AA=E3=82=81=E3=82=89=E3=81=8B=E3=81=AB=E3=81=97=E3=81=A6?= =?UTF-8?q?=E6=8F=8F=E3=81=8F=E3=81=93=E3=81=A8=E3=80=81=E3=80=8C=E7=94=9F?= =?UTF-8?q?=E3=81=AE=E5=80=A4=E3=80=8D=E3=81=A7=E3=81=AF=E8=AA=AD=E3=82=93?= =?UTF-8?q?=E3=81=A0=E3=81=BE=E3=81=BE=E3=81=AA=E3=81=93=E3=81=A8=E3=82=92?= =?UTF-8?q?=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 0e880a1..317f310 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -2,7 +2,7 @@ ## Unreleased -- パネル、カメラ映像を出した目のプレビュー: 目のカメラが見つけたまぶたの位置も映像に重ねるようにしました。上まぶたのふちに水色、下まぶたにピンクの線を引き、左下にカメラのまぶたの値(「カメラ まぶた 0.62」)を出します。線が見つからなかったフレームでは、その線を描きません。目の読み取り方は何も変わりません +- パネル、カメラ映像を出した目のプレビュー: 目のカメラが見つけたまぶたの位置も映像に重ねるようにしました。上まぶたのふちに水色、下まぶたにピンクの線を引き、左下にカメラのまぶたの値(「カメラ まぶた 0.62」)を出します。線が見つからなかったフレームでは、その線を描きません。線と値はちらつかないようなめらかにして描き、「生の値」では読んだままを描きます。目の読み取り方は何も変わりません ## 0.8.2-beta.4 (2026-10-26) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2745c6d..3df3189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Panel, eye preview with the camera picture on: the picture now also shows where the eye cameras found your eyelids, a light blue line on the upper lid's edge and a pink one on the lower lid, and the camera's eyelid value at its bottom left ("Camera lid 0.62"). A line is left out on frames where none was found. Nothing about how the eyes are read changes. +- Panel, eye preview with the camera picture on: the picture now also shows where the eye cameras found your eyelids, a light blue line on the upper lid's edge and a pink one on the lower lid, and the camera's eyelid value at its bottom left ("Camera lid 0.62"). A line is left out on frames where none was found. The lines and the value are smoothed so they don't flicker; with "Raw" they are drawn as read. Nothing about how the eyes are read changes. ## 0.8.2-beta.4 (2026-10-26) From 1026d5f5e8b27f179b7d75844bb0a25df56bd490 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Mon, 26 Oct 2026 20:59:27 +0900 Subject: [PATCH 59/65] =?UTF-8?q?=E7=9B=AE=E3=81=AE=E3=83=97=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC:=20=E3=81=BE=E3=81=B6=E3=81=9F?= =?UTF-8?q?=E3=81=AE=E7=B7=9A=E3=81=AE=E3=81=AA=E3=82=81=E3=82=89=E3=81=8B?= =?UTF-8?q?=E3=81=95=E3=81=AE=E5=89=8D=E3=81=AB=203=20=E6=9E=9A=E3=81=AE?= =?UTF-8?q?=E4=B8=AD=E5=A4=AE=E5=80=A4=E3=82=92=E5=85=A5=E3=82=8C=E3=81=A6?= =?UTF-8?q?=E3=80=811=20=E6=9E=9A=E3=81=A0=E3=81=91=E3=81=AE=E8=B7=B3?= =?UTF-8?q?=E3=81=AD=E3=82=92=E6=B6=88=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit one-euro だけでは、下まぶたの線が 1 枚だけ 5〜7 px 跳ねるのが半分の大きさで残っていた。 線の 5 つの数・ふちまでの幅・EyeLid を、直近 3 枚の中央値にしてから one-euro に通す(生の値以外だけ)。 1 枚だけの跳ねはまるごと消え、開発者の録画で下の線のジグザグの約 8 割が消える(前は約 6 割)。 そのかわり 1 枚(約 67 ms)遅れ、まばたきは 2 枚目から追う。やり直しの条件は同じで、 やり直したあとは手元の 1〜2 枚の新しいほうを使うので、線はすぐ出る。 Co-Authored-By: Claude Opus 5.5 --- panel/README.md | 2 +- panel/src/eye_preview.cpp | 23 ++++++++++++++++- panel/src/eye_preview.h | 43 ++++++++++++++++++++++++++------ panel/tests/eye_preview_test.cpp | 25 +++++++++++++++++++ 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/panel/README.md b/panel/README.md index f266911..ae9a806 100644 --- a/panel/README.md +++ b/panel/README.md @@ -38,7 +38,7 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ - 両目(255×170 の箱): アーモンド形の輪郭、白目、虹彩(高さの 0.27)と瞳孔(瞳孔の mm ÷ 11.8 mm × 虹彩の直径。無ければ 4 mm で描く)、虹彩はその目の視線で動く(左右 35°・上下 30° で端まで)。上まぶたは閉じ(0)でいちばん下、ふだんの開き(0.75)で開いた幅の 72%、見開き(1)でいちばん上まで上がる。細めで下まぶたが目の中ほどへ最大 45% 上がる。値が無い目は 0.75 のまま薄く描く。箱の外側の上の角に「左目」「右目」(その目についていく) - 眉: 本体が眉を送っているあいだ(`brow_from_widen` がオンで、眉を受け取る出力。データグラムのフラグ bit 2)だけ、目の上に眉の弧(#8b949e、目の幅の 22〜78%、太さは高さの 4%)。眉 0 で目のすぐ上に休み、眉 1 で目の高さの 30% 上がる(`eye_preview::browLift`)。送る値でだけ描く。生の値とカメラの値では描かない(眉はどこも測っていない。本体が見開きから作る値なので、「読んだ値」は無い)が、目の上の場所は空けたままにして、切り替えても目が動かないようにする。眉の場所の分だけ目のブロックが高くなり、映像を出していて足りないときは目を小さく(縦横の比はそのまま)描く。鏡・止めるも眉に効く。眉を送っていないときは今までとまったく同じ絵 - 目の下のチップ: 「まぶた 0.72」(送る値は VRCFT のまぶた、生の値は Valve の開き具合〔目ごとの倍率の後〕、カメラの値は eyecam のまぶた)、出どころのバッジ(送る値では本体の LidFrom: 0 = ふつうの混ぜ方なら「Valve」、それ以外〔カメラが開いている・閉じている・見開きなど〕は緑の「カメラ」。生の値はいつも Valve、カメラの値はいつもカメラ)、「瞳 6.1 mm」(送る値は送った瞳孔、カメラの値は eyecam の瞳孔、生の値と無いときは「瞳 —」)。描く値: 送る値 = 送ったまぶた・細め・瞳孔・視線、生の値 = Valve の開き具合(倍率の後、1.0 をふだんの開き 0.75 として描く)となめらかにする前の視線、細めと瞳孔は無し、カメラの値 = eyecam のまぶた・細め・瞳孔となめらかにする前の視線(カメラは視線を出さないので)。カメラの値が無い目は値なし - - カメラ映像(オンで、目のカメラを使えるとき): 目ごとに 176×176 の灰色の映像を目の下に。eyecam-rec は両目とも耳側を左(x の小さい側)にして上下を正しく書くので、右の列に描く目の映像を左右反転して、どちらの向きでも耳側をシートの外側にする(映像は描いた目についていく。鏡と正面は互いに鏡像になる)。その上に、見つけた瞳を緑の丸(#5fd38a。見つからなければ無し)、瞳を探す範囲を黄色の点線の枠(#f2d25a)、eyecam がそのフレームで測ったまぶたの線(上まぶたのふちを水色 #6cc4ff、下まぶたをピンク #ff8fc8。放物線を 16 本の線分で、暗い縁取りの上に。その線が無いフレームや、線の無い古い eyecam-rec のファイルでは描かない。「生の値」では読んだまま、ほかでは目ごとに映像の撮影時刻で one-euro フィルター〔止まっているとき 1 Hz、線が 1 px/秒動くごとに 0.05 Hz 上がる。速さは 1 Hz で〕をかけて描く。下まぶたの線は 1 枚だけ数 px 跳ねることが多く、開発者の録画でそのジグザグの約 6 割が消え、まばたきは 1〜2 枚で追いつく。線が無い映像・0.3 秒より空いた映像・時刻が戻った映像でその線をやり直す〔前の場所から流れてこない〕。`eye_preview::LidSmoother`)、eyecam が閉じていると読んでいるときは左上に「閉じてる判定」、左下にそのフレームで eyecam が送ったまぶたの値(「カメラ まぶた 0.62」。値が有効でなければ無し。線と同じく「生の値」以外では同じフィルター〔速さで 2.5 Hz/単位〕をかける)。映像が無い・0.5 秒より古いときは「映像を待っています」。映像はその描画の間だけメモリの上で RGB にして描き、どこにも書かない + - カメラ映像(オンで、目のカメラを使えるとき): 目ごとに 176×176 の灰色の映像を目の下に。eyecam-rec は両目とも耳側を左(x の小さい側)にして上下を正しく書くので、右の列に描く目の映像を左右反転して、どちらの向きでも耳側をシートの外側にする(映像は描いた目についていく。鏡と正面は互いに鏡像になる)。その上に、見つけた瞳を緑の丸(#5fd38a。見つからなければ無し)、瞳を探す範囲を黄色の点線の枠(#f2d25a)、eyecam がそのフレームで測ったまぶたの線(上まぶたのふちを水色 #6cc4ff、下まぶたをピンク #ff8fc8。放物線を 16 本の線分で、暗い縁取りの上に。その線が無いフレームや、線の無い古い eyecam-rec のファイルでは描かない。「生の値」では読んだまま、ほかでは目ごとに映像の撮影時刻で、直近 3 枚の中央値のあとに one-euro フィルター〔止まっているとき 1 Hz、線が 1 px/秒動くごとに 0.05 Hz 上がる。速さは 1 Hz で〕をかけて描く。下まぶたの線は 1 枚だけ 5〜7 px 跳ねることが多く、中央値でそれが消え、開発者の録画でジグザグの約 8 割が消える。そのかわり 1 枚〔約 67 ms〕遅れ、まばたきは 2 枚目から追う。線が無い映像・0.3 秒より空いた映像・時刻が戻った映像でその線をやり直し〔前の場所から流れてこない。そこからは手元の 1〜2 枚の新しいほうを使う〕。`eye_preview::LidSmoother`)、eyecam が閉じていると読んでいるときは左上に「閉じてる判定」、左下にそのフレームで eyecam が送ったまぶたの値(「カメラ まぶた 0.62」。値が有効でなければ無し。線と同じく「生の値」以外では同じフィルター〔速さで 2.5 Hz/単位〕をかける)。映像が無い・0.5 秒より古いときは「映像を待っています」。映像はその描画の間だけメモリの上で RGB にして描き、どこにも書かない - 使えないとき(eyecam-rec が動いていない・準備前・`camera_lids` がオフ・status.json に `preview` が無い古い eyecam-rec・`preview on` を断られた)は「カメラ映像」を押せなくし、スイッチの下に理由を 1 行(「目のカメラが動いていません」など)。最初の 3 つでは「カメラの値」も押せず、送る値で描く。目はカメラなしでも Valve の値で動く。映像が無いときは、目とチップを空いたところの真ん中に。使えなくなったら「カメラ映像」はオフに戻し、また使えるようになってももう一度押すまで映像は出さない。`preview on` を断られた(2 秒で返事が無いときも)ら理由を 5 秒(`kRefusedSec`)出してから、また押せるようにする。本体のサンプルが無い目には出どころのバッジを出さない - いちばん下に「視線 左右 +5°・上下 −3°」(送る値は送った視線、ほかはなめらかにする前の視線。正面から見た向きでも本人から見た向き: + が右・上)と、映像を出しているときは「映像は表示するだけで保存しません。緑の丸が見つけた瞳、黄色の枠が瞳を探す範囲、水色とピンクの線が上と下のまぶた」、出していないときは「アバターの目の形は人によって違います。動きの確認用です」。本体のサンプルが 1 秒来なければ「目のデータがありません」 - 値の受け取り方: シートが見えているあいだ(ダッシュボードが開いていて)だけ、視線の点のソケット `gaze-dots.sock` を開く(点のスイッチがオフなら点は出さない)。本体はそのソケットがあれば(1 秒に 1 回見る)サンプルごとに版 3 のデータグラム(160 バイト: 送った視線・なめらかにする前の視線と、目ごとに送ったまぶた・Valve の開き具合〔倍率の前と後〕・カメラのまぶた・送った細めと瞳孔・カメラの細めと瞳孔・LidFrom・カメラの閉じ判定・送った眉〔u16、0.8.0 から。もとは 0 の予備のバイト〕、最後に視線の倍率を掛ける前の視線。`src/dots.rs`)を送る。眉はフラグの bit 2 が立っているときだけ読む(版と大きさはそのまま: 0.7.10 までのパネルは眉を読まずにほかは同じに読み、0.7.10 までの本体のデータグラムは眉なしとして読む)。版 2(136 バイト、0.7.7 までの本体)も読む(倍率の前の視線は送った視線と同じとみなし、輪は出さない)。ほかの版は無視する diff --git a/panel/src/eye_preview.cpp b/panel/src/eye_preview.cpp index 761c966..8eb47e3 100644 --- a/panel/src/eye_preview.cpp +++ b/panel/src/eye_preview.cpp @@ -140,6 +140,27 @@ double OneEuro::step(double value, double t) { return x_; } +double LidFilter::step(double value, double t) { + if (!std::isfinite(value) || !std::isfinite(t)) { + reset(); + return value; + } + // (the same starts over as the one-euro's, which follows them by itself) + if (count_ > 0 && (t - t_ <= 0 || t - t_ > kLidResetSec)) count_ = 0; + double median = value; + if (count_ == 2) median = std::max(std::min(last_[0], last_[1]), std::min(std::max(last_[0], last_[1]), value)); + if (count_ == 2) last_[0] = last_[1]; + last_[std::min(count_, 1)] = value; + count_ = std::min(count_ + 1, 2); + t_ = t; + return euro_.step(median, t); +} + +void LidFilter::reset() { + count_ = 0; + euro_.reset(); +} + void LidSmoother::take(Pictures& pictures) { for (int e = 0; e < 2; ++e) { Picture& p = pictures.eyes[e]; @@ -149,7 +170,7 @@ void LidSmoother::take(Pictures& pictures) { eye.timeNs = p.timeNs; const double t = p.timeNs * 1e-9; // Each line as a whole: none on this picture starts all of its filters over - const auto line = [t](const LidLine& in, OneEuro* filters) { + const auto line = [t](const LidLine& in, LidFilter* filters) { LidLine out = in; if (!in.present) { for (int i = 0; i < 5; ++i) filters[i].reset(); diff --git a/panel/src/eye_preview.h b/panel/src/eye_preview.h index 40aaf55..03111be 100644 --- a/panel/src/eye_preview.h +++ b/panel/src/eye_preview.h @@ -295,9 +295,10 @@ class PictureReader { }; /** - * The lid lines' smoothing (picture px; in Hz): calm at rest (about 0.16 s), the cutoff up 0.05 Hz for each px a second - * the line moves, so a blink is followed within a picture or two. Tuned on the developer's recordings (the lower line - * jumps a few px for single pictures; about 60% of its picture-to-picture zigzag goes). + * The lid lines' smoothing (picture px; in Hz), after the median of three (LidFilter): calm at rest (about 0.16 s), the + * cutoff up 0.05 Hz for each px a second the line moves, so a blink is followed within a picture or two. Tuned on the + * developer's recordings (the lower line jumps 5 to 7 px for single pictures: the median drops those, and with the + * one-euro about 78% of its picture-to-picture zigzag goes; 60% with the one-euro alone, which halved the jumps). */ constexpr double kLidMinCutoffHz = 1.0; constexpr double kLidBeta = 0.05; @@ -343,6 +344,34 @@ class OneEuro { double t_ = 0.0; ///< ...at this time }; +/** + * One lid value's filter: the median of its last three pictures (a single picture's jump is dropped, at the cost of one + * picture's lag, 1/15 s), then the one-euro. It starts over as OneEuro does (no value, a gap of more than kLidResetSec, + * a time not after the last), with the pictures it has: the newest of one or two, so a line shows at once. + */ +class LidFilter { +public: + /** @param beta the one-euro's (kLidBeta for the lines, kEyeLidBeta for the EyeLid) */ + explicit LidFilter(double beta = kLidBeta) : euro_(kLidMinCutoffHz, beta, kLidDCutoffHz) {} + + /** + * The next value. + * @param value the value (NaN: none, and the filter starts over) + * @param t its time (s) + * @return the filtered value + */ + double step(double value, double t); + + /** Start over at the next value. */ + void reset(); + +private: + OneEuro euro_; + double last_[2] = {0.0, 0.0}; ///< the two values before, older first... + int count_ = 0; ///< ...how many of them there are + double t_ = 0.0; ///< ...and the newer one's time +}; + /** * Smooths each eye's lid lines and EyeLid over the pictures read (every source but Raw draws them smoothed). Each line * starts over where it has none on a picture, so one that comes back starts where it is. @@ -363,10 +392,10 @@ class LidSmoother { struct Eye { uint64_t count = 0; uint64_t timeNs = 0; - OneEuro upper[5]; - OneEuro lower[5]; - OneEuro margin; - OneEuro eyeLid {kLidMinCutoffHz, kEyeLidBeta, kLidDCutoffHz}; + LidFilter upper[5]; + LidFilter lower[5]; + LidFilter margin; + LidFilter eyeLid {kEyeLidBeta}; }; Eye eyes_[2]; }; diff --git a/panel/tests/eye_preview_test.cpp b/panel/tests/eye_preview_test.cpp index de1f460..394b0cc 100644 --- a/panel/tests/eye_preview_test.cpp +++ b/panel/tests/eye_preview_test.cpp @@ -367,6 +367,31 @@ void testSmoothing() { const double closing = lid.step(0.0, 20 * dt); CHECK(closing < 0.25 && lid.step(0.0, 21 * dt) < 0.05); + // With the median of three in front (LidFilter, what the smoother uses): a lone picture's jump is gone entirely... + LidFilter lone; + for (int i = 0; i < 20; ++i) lone.step(130, i * dt); + CHECK(near(lone.step(123, 20 * dt), 130) && near(lone.step(130, 21 * dt), 130)); + // ...and so is one each way in turn with a picture between + CHECK(near(lone.step(137, 22 * dt), 130) && near(lone.step(130, 23 * dt), 130)); + // A real step (a blink: 30 px) arrives within two pictures: none of it on the first (it could be a lone jump), most + // on the second, all but 1 px by the fourth + LidFilter real; + for (int i = 0; i < 20; ++i) real.step(100, i * dt); + CHECK(near(real.step(130, 20 * dt), 100)); + CHECK(real.step(130, 21 * dt) > 120); + real.step(130, 22 * dt); + CHECK(real.step(130, 23 * dt) > 129); + // The same starts over, with the pictures it has: the first value at once, the newest of two, the median from three + CHECK(std::isnan(real.step(NAN, 24 * dt)) && near(real.step(60, 25 * dt), 60) && real.step(64, 26 * dt) > 61); + CHECK(near(real.step(90, 26 * dt + kLidResetSec + 0.01), 90)); + CHECK(near(real.step(70, 1.0), 70)); + real.reset(); + CHECK(near(real.step(40, 2.0), 40)); + // The EyeLid's: a blink to closed shows from the second picture, nearly closed on the third + LidFilter blink(kEyeLidBeta); + for (int i = 0; i < 20; ++i) blink.step(0.75, i * dt); + CHECK(near(blink.step(0.0, 20 * dt), 0.75) && blink.step(0.0, 21 * dt) < 0.25 && blink.step(0.0, 22 * dt) < 0.05); + // The smoother: by picture (count and time), each line on its own, the EyeLid too LidSmoother smoother; Pictures pictures = madeUp(200); From ad3123308e4f5e6047dfd033e2bb448a4881be0d Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 27 Oct 2026 19:37:45 +0900 Subject: [PATCH 60/65] =?UTF-8?q?=E7=9B=AE=E3=81=AE=E3=83=97=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC:=20=E3=82=AB=E3=83=A1=E3=83=A9?= =?UTF-8?q?=E6=98=A0=E5=83=8F=E3=81=AB=E9=87=8D=E3=81=AD=E3=82=8B=E5=8D=B0?= =?UTF-8?q?=EF=BC=88=E3=81=BE=E3=81=B6=E3=81=9F=E3=83=BB=E7=9E=B3=E3=83=BB?= =?UTF-8?q?=E6=8E=A2=E3=81=99=E7=AF=84=E5=9B=B2=EF=BC=89=E3=82=92=E3=81=9D?= =?UTF-8?q?=E3=82=8C=E3=81=9E=E3=82=8C=E3=82=B9=E3=82=A4=E3=83=83=E3=83=81?= =?UTF-8?q?=E3=81=A7=E5=87=BA=E3=81=97=E3=81=9F=E3=82=8A=E6=B6=88=E3=81=97?= =?UTF-8?q?=E3=81=9F=E3=82=8A=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - スイッチは 2 つの映像のあいだに縦に並べる(映像を出しているときだけ。場所を取らないので目の大きさは変わらない) - 既定は全部オン、鏡と同じくパネルが動いているあいだだけ覚える(設定には残さない) - 「まぶた」は上下のまぶたの線と左下のカメラのまぶたの値をまとめて消す。「閉じてる判定」はいつも出す - --preview-sheet に :nolids / :nopupil / :nowindow を足す - テスト: 既定オン、押すとその印だけ消えて戻る、閉じて開いても残る、映像オフではスイッチなし、ほかのボタンと重ならない Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 1 + CHANGELOG.md | 1 + panel/README.md | 2 +- panel/src/eye_preview.h | 10 +++++ panel/src/i18n.cpp | 6 +++ panel/src/i18n.h | 3 ++ panel/src/main.cpp | 33 ++++++++++++----- panel/src/panel.cpp | 55 +++++++++++++++++++++++----- panel/src/panel.h | 14 +++++-- panel/tests/panel_test.cpp | 75 ++++++++++++++++++++++++++++++++++++-- 10 files changed, 174 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 317f310..c92993c 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -3,6 +3,7 @@ ## Unreleased - パネル、カメラ映像を出した目のプレビュー: 目のカメラが見つけたまぶたの位置も映像に重ねるようにしました。上まぶたのふちに水色、下まぶたにピンクの線を引き、左下にカメラのまぶたの値(「カメラ まぶた 0.62」)を出します。線が見つからなかったフレームでは、その線を描きません。線と値はちらつかないようなめらかにして描き、「生の値」では読んだままを描きます。目の読み取り方は何も変わりません +- パネル、カメラ映像を出した目のプレビュー: 2 つの映像のあいだのスイッチで、映像に重ねる印をそれぞれ出したり消したりできるようにしました。「まぶた」(上と下のまぶたの線とカメラのまぶたの値)、「瞳」(緑の丸)、「探す範囲」(黄色の枠)です。パネルを起動するたびに全部オンから始まります。「閉じてる判定」はいつも出ます ## 0.8.2-beta.4 (2026-10-26) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3df3189..a3ca9c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Panel, eye preview with the camera picture on: the picture now also shows where the eye cameras found your eyelids, a light blue line on the upper lid's edge and a pink one on the lower lid, and the camera's eyelid value at its bottom left ("Camera lid 0.62"). A line is left out on frames where none was found. The lines and the value are smoothed so they don't flicker; with "Raw" they are drawn as read. Nothing about how the eyes are read changes. +- Panel, eye preview with the camera picture on: switches between the two pictures turn each mark on them on or off: "Eyelids" (both lid lines and the camera's eyelid value), "Pupil" (the green circle) and "Search area" (the yellow frame). They start on each time the panel starts. "Read as closed" always shows. ## 0.8.2-beta.4 (2026-10-26) diff --git a/panel/README.md b/panel/README.md index ae9a806..61e2168 100644 --- a/panel/README.md +++ b/panel/README.md @@ -237,7 +237,7 @@ ctest --test-dir build --output-on-failure # 登録したテストをぜ - `--update-live` を付けると本物の更新の仕組みを動かす: 最初に確認し、`--click` のあとは始まった確認や更新が終わるまで待ってから描く。更新は本当に行われるので、偽の GitHub(`FRAME_UPDATE_API_URL`・`FRAME_UPDATE_ALLOW_INSECURE=1`)と別の `HOME` で試す - 診断は `--diag` で開いて描く(`--fake` とも、本物のファイルとも。本物なら SteamOS の版と道具の SHA-256 も本物を読む)。`--fake` の SteamOS と道具は開発者のヘッドセットの値。`--fake-eyecam` の `:prox=V`(近接センサー)・`:blocks=N`(最後に探す前に変化したブロック)・`:lasterror`(eyecam-rec の最後のエラー、3 分前)・`:camfps=N`(カメラの速さ `cam_fps`。作り物の校正のメッセージの枚数〔N fps での 5.4 秒と要る数〕も合わせ、60 未満なら eyecam-rec と同じ「カメラの映像が毎秒 N 枚しか届いていない」も付く)でその行を変えられる。最後に探したときの様子は状態から(映像があれば 8 スロット・両目、`search=…` ならそこで止まったところ)、最後の校正は `setup`・`failed=…`・`calib-error` から作る - 詳細タブの小タブは `--adv-page version|trouble|tools|files` で選ぶ(既定は version。`panel.json` は読まない)。まぶたタブの小タブは `--lids-page look|blinks|fine`(既定は look。`--lid-marks` は fine と同じ。`panel.json` は読まない)。視線タブの小タブは `--gaze-page motion|reach|glitch`(既定は motion。`panel.json` は読まない)。`--adv-scroll px` でスクロールして描く(中身の範囲に収めるので、大きな値でいちばん下) -- 目のプレビューは `--preview-sheet [SOURCE[:nomirror][:nocamera][:frozen]]`(SOURCE は sent / raw / camera。既定は sent・鏡・映像あり)で開いて描く。`--fake-preview [ok|noeyecam|nopicture|closed|nodata]` は作り物の値と灰色の絵(目ではない。本物の目の映像はテストにも資料にも使わない)で、目のカメラは準備済み(`--fake-eyecam` があればそちら。`:nopreview` で映像を出せない古い eyecam-rec): 全部ある・eyecam-rec が動いていない・映像がまだ・左目を閉じている・本体のサンプルが無い。`--fake` を含む。`--eyecam-dir DIR` の `preview` ファイルがあれば絵はそこから読む(`~/.local/lib/eyecam/eyecam-rec --replay … --preview DIR` で録画を流しながら向きを確かめる用。その PNG は外に出さない)。`--preview-bench N` で N 回描いた時間を出す。`--preview-follow SEC` は描く前に SEC 秒そのファイルを読み続け、まぶたの線をライブと同じようになめらかにしてから描く +- 目のプレビューは `--preview-sheet [SOURCE[:nomirror][:nocamera][:frozen][:nolids][:nopupil][:nowindow]]`(SOURCE は sent / raw / camera。既定は sent・鏡・映像あり・映像の上の印は全部あり。`:no…` でその印を消す)で開いて描く。`--fake-preview [ok|noeyecam|nopicture|closed|nodata]` は作り物の値と灰色の絵(目ではない。本物の目の映像はテストにも資料にも使わない)で、目のカメラは準備済み(`--fake-eyecam` があればそちら。`:nopreview` で映像を出せない古い eyecam-rec): 全部ある・eyecam-rec が動いていない・映像がまだ・左目を閉じている・本体のサンプルが無い。`--fake` を含む。`--eyecam-dir DIR` の `preview` ファイルがあれば絵はそこから読む(`~/.local/lib/eyecam/eyecam-rec --replay … --preview DIR` で録画を流しながら向きを確かめる用。その PNG は外に出さない)。`--preview-bench N` で N 回描いた時間を出す。`--preview-follow SEC` は描く前に SEC 秒そのファイルを読み続け、まぶたの線をライブと同じようになめらかにしてから描く - ほかの Frame アプリの一覧は `--apps-sheet list` で、その上の確認は `--apps-sheet confirm`(入っていない最初のアプリ)・`confirm:呼び名`・`menu`(インストーラーのメニュー)で開いて描く。`--fake` か `--fake-apps-state keyboard=missing,mic=running,perf=missing,aux=installed`(`missing` / `installed` / `running` / `busy` = Konsole が開いている。`menu=busy` でメニュー)なら同梱の一覧と作り物の状態(既定は keyboard・perf が入っていない、mic が動作中、aux が入っている)、どちらも無ければ今の入り具合と前に取った一覧。`--dump-png` からは取りに行かないし Konsole も開かない(`--click` の[Konsole で開く]はコマンドを出すだけ)。`--fake-apps-launch busy|nodisplay|nokonsole|failed` で開けなかったときの確認、`--fake-apps-extra N` でアイコンの無い作り物のアプリを N 個足す(スクロールの確認) - 記録は `--reports-dir DIR`(既定は本物の記録のフォルダ)から読む。`--record 名前|latest` でその記録、`--records-all` ですべての記録の一覧を開いて描く。`--fake-records` は作り物の 10 件(`--record N` で N 番目、0 がいちばん新しい)。`--fake-saved` で目合わせ・校正の失敗の画面に「記録したよ」の帯(`--fake-fit failed-…` か `--fake-eyecam calib-error…` と一緒に) - `--report-make 種類 始め 終わり 結果 理由`(確かめるためのもの): その時間のジャーナルと Valve のログから、パネルが走り終わったあとに書くのと同じ記録を `--reports-dir` に書き、フォルダを出す(例: `--report-make fit "2026-10-06 20:45:09" "2026-10-06 20:45:17" failed "…"`。条件と診断コードは空) diff --git a/panel/src/eye_preview.h b/panel/src/eye_preview.h index 03111be..ff94fef 100644 --- a/panel/src/eye_preview.h +++ b/panel/src/eye_preview.h @@ -22,6 +22,16 @@ namespace eye_preview { /** Which values drive the drawn eyes: as sent, as read (Valve's openness, the gaze before smoothing), the camera's. */ enum class Source { Sent, Raw, Camera }; +/** The marks drawn over the camera pictures, each with its switch on the sheet (PanelAction::PreviewMark's arg). */ +enum class Mark { Lids, Pupil, Window }; + +/** Which of them are drawn (all at first; kept while the panel runs, like the mirror). */ +struct Marks { + bool lids = true; ///< the upper and lower lid lines, and the camera's eyelid under them + bool pupil = true; ///< the ring on the pupil found + bool window = true; ///< the dashed frame the pupil is looked for in +}; + /** One drawn eye. */ struct EyeShown { bool known = false; ///< there is an eyelid to draw (else drawn faded, relaxed open) diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index 9861ef3..5736d49 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -58,6 +58,9 @@ UiText makeJapanese() { t.previewSourceCamera = "カメラの値"; t.previewMirror = "鏡で見る"; t.previewPicture = "カメラ映像"; + t.previewMarkLids = "まぶた"; + t.previewMarkPupil = "瞳"; + t.previewMarkWindow = "探す範囲"; t.previewFreeze = "止める"; t.previewResume = "動かす"; t.previewLidFormat = "まぶた %.2f"; @@ -1112,6 +1115,9 @@ UiText makeEnglish() { t.previewSourceCamera = "Camera"; t.previewMirror = "Mirror"; t.previewPicture = "Camera picture"; + t.previewMarkLids = "Eyelids"; + t.previewMarkPupil = "Pupil"; + t.previewMarkWindow = "Search area"; t.previewFreeze = "Freeze"; t.previewResume = "Resume"; t.previewLidFormat = "Lid %.2f"; diff --git a/panel/src/i18n.h b/panel/src/i18n.h index 8263798..ac38a54 100644 --- a/panel/src/i18n.h +++ b/panel/src/i18n.h @@ -66,6 +66,9 @@ struct UiText { const char* previewSourceCamera; ///< ...the eye camera's own const char* previewMirror; ///< the switch that shows the eyes as in a mirror (off: seen from the front) const char* previewPicture; ///< the switch for the eye cameras' pictures + const char* previewMarkLids; ///< the switches for the marks over them: the eyelids' (and their value)... + const char* previewMarkPupil; ///< ...the ring on the pupil... + const char* previewMarkWindow; ///< ...the frame the pupil is looked for in const char* previewFreeze; ///< stop the drawing where it is const char* previewResume; ///< ...and let it move again const char* previewLidFormat; ///< an eye's chip: its eyelid (%.2f) diff --git a/panel/src/main.cpp b/panel/src/main.cpp index c327024..ca52af3 100644 --- a/panel/src/main.cpp +++ b/panel/src/main.cpp @@ -283,9 +283,10 @@ void printUsage() { " otherwise as installed now (the list fetched before; never fetched here)\n" " --fake-apps-launch busy|nodisplay|nokonsole|failed Why Konsole didn't open (with --apps-sheet confirm)\n" " --fake-apps-extra N Add N made-up apps without an icon to the list (it scrolls past four or five)\n" - " --preview-sheet [SOURCE[:nomirror][:nocamera][:frozen]] The eye preview open (SOURCE sent, raw or\n" - " camera; default sent, in a mirror, with the camera pictures). Its pictures are read from\n" - " eyecam-rec's preview file in --eyecam-dir when it is there (eyecam-rec --replay\n" + " --preview-sheet [SOURCE[:nomirror][:nocamera][:frozen][:nolids][:nopupil][:nowindow]] The eye preview\n" + " open (SOURCE sent, raw or camera; default sent, in a mirror, with the camera\n" + " pictures and all their marks; :no... leaves that mark out). Its pictures are read\n" + " from eyecam-rec's preview file in --eyecam-dir when it is there (eyecam-rec --replay\n" " --preview), else made up with --fake-preview\n" " --fake-preview [ok|noeyecam|nopicture|closed|nodata] Made-up preview values and grey pictures (not an\n" " eye), with the eye cameras set up: all there (ok), eyecam-rec not running, no picture\n" @@ -429,19 +430,23 @@ void printUsage() { } /** - * Take --preview-sheet's value apart: sent, raw or camera, then any of :nomirror, :nocamera, :frozen ("" = as it opens). + * Take --preview-sheet's value apart: sent, raw or camera, then any of :nomirror, :nocamera, :frozen, :nolids, + * :nopupil, :nowindow ("" = as it opens). * @param text the value * @param source which values drive the drawn eyes * @param mirror as in a mirror * @param camera with the eye cameras' pictures * @param frozen stopped + * @param marks which marks are drawn over the pictures * @return false if something in it is unknown */ -bool parsePreviewSpec(const std::string& text, eye_preview::Source& source, bool& mirror, bool& camera, bool& frozen) { +bool parsePreviewSpec(const std::string& text, eye_preview::Source& source, bool& mirror, bool& camera, bool& frozen, + eye_preview::Marks& marks) { source = eye_preview::Source::Sent; mirror = true; camera = true; frozen = false; + marks = eye_preview::Marks {}; if (text.empty()) return true; size_t from = 0; for (bool first = true;; first = false) { @@ -457,6 +462,12 @@ bool parsePreviewSpec(const std::string& text, eye_preview::Source& source, bool camera = false; } else if (!first && part == "frozen") { frozen = true; + } else if (!first && part == "nolids") { + marks.lids = false; + } else if (!first && part == "nopupil") { + marks.pupil = false; + } else if (!first && part == "nowindow") { + marks.window = false; } else { return false; } @@ -829,9 +840,11 @@ bool parseOptions(int argc, char** argv, Options& options) { bool mirror = true; bool camera = true; bool frozen = false; - if (!parsePreviewSpec(options.previewSpec, source, mirror, camera, frozen)) { + eye_preview::Marks marks; + if (!parsePreviewSpec(options.previewSpec, source, mirror, camera, frozen, marks)) { std::fprintf(stderr, - "--preview-sheet takes sent, raw or camera, then :nomirror, :nocamera, :frozen: %s\n", + "--preview-sheet takes sent, raw or camera, then :nomirror, :nocamera, :frozen, " + ":nolids, :nopupil, :nowindow: %s\n", options.previewSpec.c_str()); return false; } @@ -2407,9 +2420,10 @@ int runDumpPng(const Options& options) { bool mirror = true; bool camera = true; bool frozen = false; - parsePreviewSpec(options.previewSpec, source, mirror, camera, frozen); + eye_preview::Marks marks; + parsePreviewSpec(options.previewSpec, source, mirror, camera, frozen, marks); panel.openPreview(); - panel.setPreviewOptions(source, mirror, camera, frozen); + panel.setPreviewOptions(source, mirror, camera, frozen, marks); // The pictures from eyecam-rec's preview file when it is there (eyecam-rec --replay --preview DIR, with // --eyecam-dir DIR); a file of another kind says why // (--preview-follow: every picture of the next seconds, the lid lines smoothed over them as live) @@ -3263,6 +3277,7 @@ void applyHit(const PanelHit& hit, PanelModel& model, EyePanel& panel, Autostart case PanelAction::PreviewSource: case PanelAction::PreviewMirror: case PanelAction::PreviewCamera: + case PanelAction::PreviewMark: case PanelAction::PreviewFreeze: return; // handled inside the panel; the loop follows previewOpen case PanelAction::RecordOpen: openRecordView(model, panel.recordShown()); return; case PanelAction::RecordsAll: loadRecords(model); return; diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index 0f1b274..355c2af 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -763,7 +763,7 @@ std::vector sentences(const std::string& text) { bool previewSheetAction(PanelAction action) { return action == PanelAction::PreviewClose || action == PanelAction::PreviewSource || action == PanelAction::PreviewMirror || action == PanelAction::PreviewCamera || - action == PanelAction::PreviewFreeze; + action == PanelAction::PreviewMark || action == PanelAction::PreviewFreeze; } /** @@ -912,6 +912,13 @@ PanelHit EyePanel::pointerDown(double x, double y, double now) { previewCamera_ = !previewCamera_; previewCameraKept_ = previewCamera_; return {}; + case PanelAction::PreviewMark: + switch (static_cast(hit.arg)) { + case eye_preview::Mark::Lids: previewMarks_.lids = !previewMarks_.lids; break; + case eye_preview::Mark::Pupil: previewMarks_.pupil = !previewMarks_.pupil; break; + case eye_preview::Mark::Window: previewMarks_.window = !previewMarks_.window; break; + } + return {}; case PanelAction::PreviewFreeze: // Stopped at what the next draw shows (the model isn't at hand here) previewFrozen_ = !previewFrozen_; @@ -1318,12 +1325,14 @@ void EyePanel::openPreview() { eyecamConfirm_.close(); } -void EyePanel::setPreviewOptions(eye_preview::Source source, bool mirror, bool camera, bool frozen) { +void EyePanel::setPreviewOptions(eye_preview::Source source, bool mirror, bool camera, bool frozen, + const eye_preview::Marks& marks) { previewSource_ = source; previewMirror_ = mirror; previewCamera_ = camera; previewFrozen_ = frozen; previewFreezePending_ = frozen; + previewMarks_ = marks; } void EyePanel::openAppsConfirm(const std::string& key) { @@ -7506,7 +7515,8 @@ void EyePanel::drawPreviewEye(const Pen& pen, const eye_preview::EyeShown& eye, } void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_preview::Picture& picture, bool fresh, - double x, double y, double side, bool flip, bool smooth) { + double x, double y, double side, bool flip, bool smooth, + const eye_preview::Marks& marks) { cairo_t* cr = pen.cr; const double r = 12; fillRounded(pen, x, y, side, side, r, kBg); @@ -7545,7 +7555,8 @@ void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_pre const auto px = [&](double v) { return flip ? x + side - v * scale : x + v * scale; }; const auto py = [&](double v) { return y + v * scale; }; const double* win = picture.window; - if (std::isfinite(win[0]) && std::isfinite(win[1]) && std::isfinite(win[2]) && std::isfinite(win[3])) { + if (marks.window && std::isfinite(win[0]) && std::isfinite(win[1]) && std::isfinite(win[2]) && + std::isfinite(win[3])) { const double left = std::min(px(win[0]), px(win[2])); const double right = std::max(px(win[0]), px(win[2])); const double dash[2] = {6, 4}; @@ -7580,11 +7591,13 @@ void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_pre cairo_stroke(cr); cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT); }; - if (picture.lids) { + if (marks.lids && picture.lids) { lidLine(lids.upper, std::isfinite(lids.margin) ? lids.margin : 0.0, kPreviewLidUpper); lidLine(lids.lower, 0.0, kPreviewLidLower); } - if (picture.pupil) drawRing(cr, px(picture.pupilX), py(picture.pupilY), picture.pupilR * scale, 2.5, kPreviewPupil); + if (marks.pupil && picture.pupil) { + drawRing(cr, px(picture.pupilX), py(picture.pupilY), picture.pupilR * scale, 2.5, kPreviewPupil); + } const auto badge = [&](double by, const char* text) { const double size = 13; const double w = pen.measure(text, size, true) + 18; @@ -7594,8 +7607,8 @@ void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_pre pen.text(x + 17, centerBaseline(by, 24, size), text, size, kText, true); }; if (picture.closed) badge(y + 8, t.previewClosed); - // The EyeLid eyecam-rec sent for this very frame, under its lines - if (picture.lids && std::isfinite(lids.eyeLid)) { + // The EyeLid eyecam-rec sent for this very frame, under its lines (and off with them) + if (marks.lids && picture.lids && std::isfinite(lids.eyeLid)) { char lid[64]; std::snprintf(lid, sizeof(lid), t.previewPictureLidFormat, lids.eyeLid); badge(y + side - 32, lid); @@ -7765,7 +7778,31 @@ void EyePanel::drawPreview(const Pen& pen, const UiText& t, const PanelModel& m) const eye_preview::Picture& picture = pv.pictures.eyes[eye]; const bool fresh = pv.hasPictures && eye_preview::pictureFresh(picture, pv.nowNs); drawPreviewPicture(pen, t, picture, fresh, cx - pictureSide / 2, chipsY + chipsH + 10, pictureSide, - eye_preview::pictureFlipped(eye, previewMirror_), source != Source::Raw); + eye_preview::pictureFlipped(eye, previewMirror_), source != Source::Raw, previewMarks_); + } + } + // The marks' switches, one under the other in the space between the two pictures (they go for both; nothing + // else is there, so the eyes keep their size) + if (pictures) { + using eye_preview::Mark; + const struct { + const char* label; + Mark mark; + bool on; + } switches[] = {{t.previewMarkLids, Mark::Lids, previewMarks_.lids}, + {t.previewMarkPupil, Mark::Pupil, previewMarks_.pupil}, + {t.previewMarkWindow, Mark::Window, previewMarks_.window}}; + const double spaceW = half - pictureSide - 2 * gap; + double switchW = 0; + for (const auto& s : switches) switchW = std::max(switchW, pen.measure(s.label, 16, true) + 58); + switchW = std::min(switchW, spaceW); + const double switchGap = 10; + const double pictureY = top + eyeH + 6 + chipsH + 10; + double switchY = pictureY + (pictureSide - (rowH * 3 + switchGap * 2)) / 2; + for (const auto& s : switches) { + drawToggle(pen, kInnerX + half - switchW / 2, switchY, switchW, rowH, s.label, + {PanelAction::PreviewMark, nullptr, static_cast(s.mark)}, s.on, true); + switchY += rowH + switchGap; } } diff --git a/panel/src/panel.h b/panel/src/panel.h index 02c832f..1caef7b 100644 --- a/panel/src/panel.h +++ b/panel/src/panel.h @@ -128,6 +128,7 @@ enum class PanelAction { PreviewSource, ///< which values drive the drawn eyes: arg = eye_preview::Source (handled inside) PreviewMirror, ///< show the eyes as in a mirror, or as seen from the front (handled inside) PreviewCamera, ///< the eye cameras' pictures on / off (handled inside; the caller follows previewPictures) + PreviewMark, ///< one of the marks over the pictures on / off: arg = eye_preview::Mark (handled inside) PreviewFreeze, ///< stop the drawing where it is, or let it move again (handled inside) ContribOpen, ///< Debug tools' "Record for squint training": open its sheet, arg = contrib::Page (the panel ///< opens it; the caller forgets the last recording's end and failure) @@ -429,8 +430,13 @@ class EyePanel { * @param mirror as in a mirror * @param camera the eye cameras' pictures * @param frozen stopped (at the model drawn next) + * @param marks which marks are drawn over the pictures */ - void setPreviewOptions(eye_preview::Source source, bool mirror, bool camera, bool frozen); + void setPreviewOptions(eye_preview::Source source, bool mirror, bool camera, bool frozen, + const eye_preview::Marks& marks); + + /** @return which marks are drawn over the pictures */ + const eye_preview::Marks& previewMarks() const { return previewMarks_; } /** * The widening sensitivity slider was let go of (once per release). It keeps showing that value until @@ -751,7 +757,8 @@ class EyePanel { bool previewCamera_ = false; ///< ...with the eye cameras' pictures (off whenever the cameras can't give them) bool previewCameraKept_ = false; ///< ...the pictures' switch as last pressed, for the next time it opens (this ///< run of the panel only; the cameras going away turns it off too) - bool contribOpen_ = false; ///< "Record for squint training" is shown on the Advanced tab + eye_preview::Marks previewMarks_; ///< ...which marks are drawn over them + bool contribOpen_ = false; ///< "Record for squint training" is shown on the Advanced tab contrib::Page contribPage_ = contrib::Page::Record; ///< ...opened as "Record" or "Recordings" bool contribAgreed_ = false; ///< ..."Agree and continue" pressed (the question about glasses) bool contribConfirm_ = false; ///< ...with the confirmation before deleting @@ -843,9 +850,10 @@ class EyePanel { * @param side its size (px) * @param flip mirrored left to right * @param smooth the lid lines and EyeLid smoothed over the pictures (else as read: Raw) + * @param marks which marks to draw ("Read as closed" always is) */ void drawPreviewPicture(const Pen& pen, const UiText& t, const eye_preview::Picture& picture, bool fresh, - double x, double y, double side, bool flip, bool smooth); + double x, double y, double side, bool flip, bool smooth, const eye_preview::Marks& marks); /** * A switch as a pill: a check, bold and the accent while on. diff --git a/panel/tests/panel_test.cpp b/panel/tests/panel_test.cpp index 0cca8d7..49b11a2 100644 --- a/panel/tests/panel_test.cpp +++ b/panel/tests/panel_test.cpp @@ -1948,7 +1948,8 @@ void testFitRunning(const FontSet& fonts) { for (const EyePanel::HitArea& area : over.hitAreas()) { const PanelAction a = area.hit.action; CHECK(a == PanelAction::Tab || a == PanelAction::PreviewClose || a == PanelAction::PreviewSource || - a == PanelAction::PreviewMirror || a == PanelAction::PreviewFreeze || a == PanelAction::PreviewCamera); + a == PanelAction::PreviewMirror || a == PanelAction::PreviewFreeze || + a == PanelAction::PreviewCamera || a == PanelAction::PreviewMark); CHECK(a == PanelAction::Tab || area.x >= 416); } // Its own buttons still work (the fit goes on behind it) @@ -2741,7 +2742,7 @@ bool overlaps(const EyePanel::HitArea& a, const EyePanel::HitArea& b) { } /** - * The eye preview's own buttons (the cross, the choices, the switches, freeze) as last drawn. + * The eye preview's own buttons (the cross, the choices, the switches, freeze, the marks' switches) as last drawn. * @param panel the panel * @return them */ @@ -2750,7 +2751,7 @@ std::vector previewButtons(const EyePanel& panel) { for (const EyePanel::HitArea& area : panel.hitAreas()) { const PanelAction a = area.hit.action; if (a == PanelAction::PreviewClose || a == PanelAction::PreviewSource || a == PanelAction::PreviewMirror || - a == PanelAction::PreviewCamera || a == PanelAction::PreviewFreeze) { + a == PanelAction::PreviewCamera || a == PanelAction::PreviewMark || a == PanelAction::PreviewFreeze) { found.push_back(area); } } @@ -2800,7 +2801,7 @@ void testPreview(const FontSet& fonts) { const PanelAction a = area.hit.action; const bool ownButton = a == PanelAction::PreviewClose || a == PanelAction::PreviewSource || a == PanelAction::PreviewMirror || a == PanelAction::PreviewCamera || - a == PanelAction::PreviewFreeze; + a == PanelAction::PreviewMark || a == PanelAction::PreviewFreeze; if (sheet) CHECK(ownButton); } // The button stays, now closing it @@ -2832,6 +2833,72 @@ void testPreview(const FontSet& fonts) { press(panel, PanelAction::PreviewCamera); CHECK(panel.previewPictures()); panel.render(m); + + // With the pictures: a switch for each mark over them, all on at first, one under the other in the space + // between the two pictures (176 px wide, centred in each half of the sheet), apart from the other buttons + { + const std::vector marks = hits(panel, PanelAction::PreviewMark); + CHECK(marks.size() == 3); + CHECK(panel.previewMarks().lids && panel.previewMarks().pupil && panel.previewMarks().window); + const double half = (1154 - 438) / 2.0; + for (const EyePanel::HitArea& a : marks) { + CHECK(a.x >= 438 + half / 2 + 88 && a.x + a.w <= 438 + half * 1.5 - 88); + // (above the footer's gaze line) + CHECK(a.y >= 96 && a.y + a.h <= 676 - 36 - 22); + } + const std::vector all = previewButtons(panel); + CHECK(all.size() == 10); + for (size_t i = 0; i < all.size(); ++i) { + for (size_t j = i + 1; j < all.size(); ++j) CHECK(!overlaps(all[i], all[j])); + } + // Each takes its mark off the pictures and puts it back; the sheet stays open + // (the two pictures' boxes only, without the switches between them) + const auto pictureBoxes = [&](const std::vector& rgba) { + std::vector out; + for (int y = 440; y < 620; ++y) { + for (const int left : {529, 887}) { + const size_t from = (static_cast(y) * panel.width() + left) * 4; + out.insert(out.end(), rgba.begin() + from, rgba.begin() + from + 176 * 4); + } + } + return out; + }; + panel.pointerLeave(); + panel.render(m); + const std::vector every = panel.toRgba(); + for (const eye_preview::Mark mark : + {eye_preview::Mark::Lids, eye_preview::Mark::Pupil, eye_preview::Mark::Window}) { + CHECK(pressArg(panel, PanelAction::PreviewMark, static_cast(mark)).action == PanelAction::None); + CHECK(panel.previewOpen() && panel.previewPictures()); + const eye_preview::Marks& shown = panel.previewMarks(); + CHECK(shown.lids == (mark != eye_preview::Mark::Lids)); + CHECK(shown.pupil == (mark != eye_preview::Mark::Pupil)); + CHECK(shown.window == (mark != eye_preview::Mark::Window)); + panel.pointerLeave(); + panel.render(m); + CHECK(pictureBoxes(panel.toRgba()) != pictureBoxes(every)); + pressArg(panel, PanelAction::PreviewMark, static_cast(mark)); + panel.pointerLeave(); + panel.render(m); + CHECK(panel.toRgba() == every); + } + // Kept when it closes and opens again (while the panel runs) + pressArg(panel, PanelAction::PreviewMark, static_cast(eye_preview::Mark::Pupil)); + press(panel, PanelAction::PreviewClose); + panel.render(m); + press(panel, PanelAction::PreviewOpen); + panel.render(m); + CHECK(panel.previewMarks().lids && !panel.previewMarks().pupil && panel.previewMarks().window); + // Without the pictures: no switches for them (and no room kept) + press(panel, PanelAction::PreviewCamera); + panel.render(m); + CHECK(hits(panel, PanelAction::PreviewMark).empty() && previewButtons(panel).size() == 7); + press(panel, PanelAction::PreviewCamera); + panel.render(m); + pressArg(panel, PanelAction::PreviewMark, static_cast(eye_preview::Mark::Pupil)); + CHECK(panel.previewMarks().pupil); + panel.render(m); + } pressArg(panel, PanelAction::PreviewSource, static_cast(eye_preview::Source::Raw)); panel.render(m); press(panel, PanelAction::PreviewMirror); From 92e9a08aec552489fe5b5930535edc9be3028596 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 27 Oct 2026 19:45:03 +0900 Subject: [PATCH 61/65] =?UTF-8?q?=E7=9B=AE=E3=81=AE=E3=83=97=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC:=20=E3=81=BE=E3=81=B6=E3=81=9F?= =?UTF-8?q?=E3=82=92=E7=B7=9A=E3=81=A7=E3=81=AF=E3=81=AA=E3=81=8F=E3=80=81?= =?UTF-8?q?=E7=9E=B3=E3=81=AE=E5=88=97=E3=81=AB=E7=82=B9=201=20=E3=81=A4?= =?UTF-8?q?=E3=81=9A=E3=81=A4=E3=81=A7=E6=8F=8F=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 線の端が映像ごとに跳ねて、まぶたの値は合っているのに壊れて見えるため。 - 上まぶたのふち(水色)と下まぶた(ピンク)を、半径 4.5 px の点を暗い縁取り(半径 6.5 px)の上に描く - 点の列は eyecam が開きを測る瞳の中心の列(feat.rs の y_at = 線の瞳の中心 cx での高さ)。瞳が無いフレームは線の真ん中。どちらも線の端の列のあいだに収める - なめらかにする・「生の値」では読んだまま・「まぶた」スイッチ・カメラのまぶたの値・「閉じてる判定」はそのまま - 映像の下の説明、更新履歴(Unreleased の行を点に書き直す)、README を点に合わせ、README に印のスイッチも書く Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 4 ++-- CHANGELOG.md | 4 ++-- README.ja.md | 2 +- README.md | 2 +- panel/README.md | 4 ++-- panel/src/eye_preview.h | 2 +- panel/src/i18n.cpp | 4 ++-- panel/src/panel.cpp | 41 +++++++++++++++++++---------------------- panel/src/panel.h | 2 +- panel/src/theme.h | 2 +- 10 files changed, 32 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index c92993c..5633972 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -2,8 +2,8 @@ ## Unreleased -- パネル、カメラ映像を出した目のプレビュー: 目のカメラが見つけたまぶたの位置も映像に重ねるようにしました。上まぶたのふちに水色、下まぶたにピンクの線を引き、左下にカメラのまぶたの値(「カメラ まぶた 0.62」)を出します。線が見つからなかったフレームでは、その線を描きません。線と値はちらつかないようなめらかにして描き、「生の値」では読んだままを描きます。目の読み取り方は何も変わりません -- パネル、カメラ映像を出した目のプレビュー: 2 つの映像のあいだのスイッチで、映像に重ねる印をそれぞれ出したり消したりできるようにしました。「まぶた」(上と下のまぶたの線とカメラのまぶたの値)、「瞳」(緑の丸)、「探す範囲」(黄色の枠)です。パネルを起動するたびに全部オンから始まります。「閉じてる判定」はいつも出ます +- パネル、カメラ映像を出した目のプレビュー: 目のカメラが見つけたまぶたの位置も映像に重ねるようにしました。瞳の上と下に、上まぶたのふちに水色、下まぶたにピンクの点を打ち、左下にカメラのまぶたの値(「カメラ まぶた 0.62」)を出します。まぶたが見つからなかったフレームでは、その点を描きません。点と値はちらつかないようなめらかにして描き、「生の値」では読んだままを描きます。目の読み取り方は何も変わりません +- パネル、カメラ映像を出した目のプレビュー: 2 つの映像のあいだのスイッチで、映像に重ねる印をそれぞれ出したり消したりできるようにしました。「まぶた」(上と下のまぶたの点とカメラのまぶたの値)、「瞳」(緑の丸)、「探す範囲」(黄色の枠)です。パネルを起動するたびに全部オンから始まります。「閉じてる判定」はいつも出ます ## 0.8.2-beta.4 (2026-10-26) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3ca9c1..a27f6b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## Unreleased -- Panel, eye preview with the camera picture on: the picture now also shows where the eye cameras found your eyelids, a light blue line on the upper lid's edge and a pink one on the lower lid, and the camera's eyelid value at its bottom left ("Camera lid 0.62"). A line is left out on frames where none was found. The lines and the value are smoothed so they don't flicker; with "Raw" they are drawn as read. Nothing about how the eyes are read changes. -- Panel, eye preview with the camera picture on: switches between the two pictures turn each mark on them on or off: "Eyelids" (both lid lines and the camera's eyelid value), "Pupil" (the green circle) and "Search area" (the yellow frame). They start on each time the panel starts. "Read as closed" always shows. +- Panel, eye preview with the camera picture on: the picture now also shows where the eye cameras found your eyelids, a light blue dot on the upper lid's edge and a pink one on the lower lid, above and below the pupil, and the camera's eyelid value at its bottom left ("Camera lid 0.62"). A dot is left out on frames where that lid wasn't found. The dots and the value are smoothed so they don't flicker; with "Raw" they are drawn as read. Nothing about how the eyes are read changes. +- Panel, eye preview with the camera picture on: switches between the two pictures turn each mark on them on or off: "Eyelids" (both lid dots and the camera's eyelid value), "Pupil" (the green circle) and "Search area" (the yellow frame). They start on each time the panel starts. "Read as closed" always shows. ## 0.8.2-beta.4 (2026-10-26) diff --git a/README.ja.md b/README.ja.md index 16cec0e..8ad47c3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -126,7 +126,7 @@ sudo は要りません。全部ホームフォルダ(`~/.local/bin`、`~/.loc | ![詳細のタブ](docs/images/panel-advanced-ja_2026-10-16_23-30-00.png) | | - 左の列には、いつでも今の状態が出ます: 送信中か止めているか、送り先、毎秒の送信回数、目のデータが毎秒何回来ているか(60 未満は赤で「少なめ」。その下に、遅いのが本体か Frame か)、左右のまぶたと視線(生の値と送った値)、設定のエラー。「Track Dominant Eye Only」の設定がオンのあいだは、視線の見出しの行に、Frame がどちらの目で追っているかが出ます(「Frame の設定: 右目だけで追っています」) -- 目のプレビュー: 左の列のいちばん下のボタンで、タブの上にシートを開き、両目の動きをそのまま描きます: まぶた(0.75 がふだんの開き。見開くとまぶたが上がる)、細め(下まぶたが上がる)、瞳孔の大きさ、目の向き。描く値は「送る値」「生の値」(Valve の開き具合と、なめらかにする前の視線)「カメラの値」(目のカメラが読んだ値)から選べます。目の下に、まぶたの値・どこから来たか(「Valve」か「カメラ」)・瞳孔の mm。「鏡で見る」(最初はオン)は鏡に映した向きで、右目が右に出ます(オフでほかの人や正面のアバターから見た向きになり、右目が左に出ます)。「止める」で描くのを止めます。目のカメラを使っているときは「カメラ映像」(うっかり映らないよう最初はオフ。一度オンにすると、パネルを起動し直すか、オフにするか、カメラが止まるまで、開くたびにオンのまま)で、目ごとの今のカメラの映像と、eyecam が見つけた瞳(緑の丸)・瞳を探す範囲(黄色の枠)・上と下のまぶた(水色とピンクの線。そのフレームで見つからなければ出ません。「生の値」では読んだまま、ほかではちらつかないようなめらかにします)・そのフレームでカメラが読んだまぶたの値・「閉じてる判定」も出します。映像は表示するだけで保存しません([プライバシー](#プライバシー))。アバターの目の形は人によって違うので、動きの確認用です。タブ・✕・ダッシュボードを閉じると閉じます +- 目のプレビュー: 左の列のいちばん下のボタンで、タブの上にシートを開き、両目の動きをそのまま描きます: まぶた(0.75 がふだんの開き。見開くとまぶたが上がる)、細め(下まぶたが上がる)、瞳孔の大きさ、目の向き。描く値は「送る値」「生の値」(Valve の開き具合と、なめらかにする前の視線)「カメラの値」(目のカメラが読んだ値)から選べます。目の下に、まぶたの値・どこから来たか(「Valve」か「カメラ」)・瞳孔の mm。「鏡で見る」(最初はオン)は鏡に映した向きで、右目が右に出ます(オフでほかの人や正面のアバターから見た向きになり、右目が左に出ます)。「止める」で描くのを止めます。目のカメラを使っているときは「カメラ映像」(うっかり映らないよう最初はオフ。一度オンにすると、パネルを起動し直すか、オフにするか、カメラが止まるまで、開くたびにオンのまま)で、目ごとの今のカメラの映像と、eyecam が見つけた瞳(緑の丸)・瞳を探す範囲(黄色の枠)・上と下のまぶた(瞳の上と下に水色とピンクの点。そのフレームで見つからなければ出ません。「生の値」では読んだまま、ほかではちらつかないようなめらかにします)・そのフレームでカメラが読んだまぶたの値・「閉じてる判定」も出します。2 つの映像のあいだのスイッチ(「まぶた」「瞳」「探す範囲」。最初は全部オン)で、それぞれの印を出したり消したりできます。映像は表示するだけで保存しません([プライバシー](#プライバシー))。アバターの目の形は人によって違うので、動きの確認用です。タブ・✕・ダッシュボードを閉じると閉じます - 基本: 送信の一時停止、送り先(3 枚のカード: VRChat に直接・VRCFT(LiveLink、おすすめ)・VRCFT(FrameEyeOSC、全部入り)。それぞれほかの人からの見え方(FrameEyeOSC は細めと瞳孔も届くので ◎)と、PC に何が要るかを表示。「VRChat に直接」のときは、カードの下に「目が動かないアバターは、VRCFT(LiveLink か FrameEyeOSC)にすると動きます」と灰色で 1 行。カードの下に「削除予定」の付いた小さな[VRCFT(ETVR)]のボタン。ETVR に送っているときは、その上に「次の版で ETVR はなくなるので、3 つからほかの送り先を選び直して」というオレンジの案内が出ます)、目のカメラ(準備前は「使っていない」と目のカメラタブへの[はじめる →]、準備後は「使う / 使わない」(`camera_lids`)と「左・右 OK」などの短い状態)、言語(日本語 / English。右に[すべて既定に戻す])、SteamVR と一緒に起動(右に[アプリを終了]) - 送り方: 送り先の PC(自動、今送っている PC で固定、または入力。下を参照)とポート。VRChat に直接のときはパラメーター名の頭、EyeTrackingActive の型、Steam Link の名前も送るか、VRChat 標準の目も動かすか、LiveLink・FrameEyeOSC・ETVR のときは PC の VRCFaceTracking で準備すること(FrameEyeOSC は 4 ステップで、モジュールの入手先のアドレス付き。押すと Frame のブラウザーでそのページを開きます)。LiveLink で目のカメラを使っているときは「瞳孔は VRChat に直接送る」(`pupils_to_vrchat`)も。目のカメラを使っていて VRChat に直接か LiveLink のときは「瞳孔の受け取り方」(`pupil_bits`)も。こういう目のカメラがないと使えない行には緑の「カメラ」バッジが付き、カメラを使っていないとき(準備前か「使わない」)は、代わりに点線の 1 行(押すと目のカメラタブ)が出ます - 視線: いちばん上の切り替えで 3 ページ(初めては「動き」、あとは最後に開いたページ。パネルを起動し直しても開きます)。「動き」: スムージングのオン / オフ、なめらかさの弱 / 中 / 強と 3 つの値、見つめている時の遊び。「動く範囲」: 視線の倍率(目が左・右・上・下へどこまで動くか。5〜200 %。目合わせのあとに掛け、目合わせでは変わりません。左右・上下のあいだの鍵を閉じているあいだは、両方いっしょに変わります)、左右の目を別々に動かす。「乱れ対策」: まばたき中は視線を止める、不確かな視線を使わない、一瞬の途切れを消す diff --git a/README.md b/README.md index 0fa50cf..a2f0cec 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ To go back, turn "Get test versions" off. Your test version stays installed unti | ![The Advanced tab](docs/images/panel-advanced-en_2026-10-16_23-30-00.png) | | - The left column always shows what frameeyeosc is doing: sending or paused, where it sends to, messages per second, how many samples a second the eye tracker delivers (marked "low" below 60, with a line under it saying whether frameeyeosc or the Frame was the slow one), both eyelids and the gaze (raw and sent), and a config error if there is one. While the "Track Dominant Eye Only" setting is on, the gaze title line says which eye the Frame tracks ("Frame setting: tracking the right eye only"). -- Eye preview: the button at the bottom of the left column opens a sheet over the tabs that draws both eyes as they move: the eyelid (0.75 is relaxed open; widening raises the lid), the squint (the lower lid rises), the pupil's size and where the eye looks, from the values as sent, as read ("Raw": Valve's openness and the gaze before smoothing) or as the eye cameras read them ("Camera"). Under each eye: its eyelid, where it came from ("Valve" or "Camera") and its pupil in mm. "Mirror" (on at first) shows the eyes as in a mirror, your right eye on the right (off: as someone facing you, or your avatar from the front, sees them, your right eye on the left); "Freeze" stops the drawing. With the eye cameras in use, "Camera picture" (off at first, so your eyes never show by accident; once you turn it on, it stays on each time the preview opens until the panel restarts, you turn it off, or the cameras stop) also shows each eye's live camera picture with the pupil eyecam found (green circle), where it looks for it (yellow frame), the upper and lower eyelids it found (blue and pink lines; none on a frame where it found none; as read with "Raw", smoothed so they don't flicker otherwise), the eyelid value the camera read on that frame and "Read as closed". The picture is only shown, never saved (see [Privacy](#privacy)). Avatars' eyes differ, so the drawing is for checking the movement. Any tab, the cross or closing the dashboard closes it. +- Eye preview: the button at the bottom of the left column opens a sheet over the tabs that draws both eyes as they move: the eyelid (0.75 is relaxed open; widening raises the lid), the squint (the lower lid rises), the pupil's size and where the eye looks, from the values as sent, as read ("Raw": Valve's openness and the gaze before smoothing) or as the eye cameras read them ("Camera"). Under each eye: its eyelid, where it came from ("Valve" or "Camera") and its pupil in mm. "Mirror" (on at first) shows the eyes as in a mirror, your right eye on the right (off: as someone facing you, or your avatar from the front, sees them, your right eye on the left); "Freeze" stops the drawing. With the eye cameras in use, "Camera picture" (off at first, so your eyes never show by accident; once you turn it on, it stays on each time the preview opens until the panel restarts, you turn it off, or the cameras stop) also shows each eye's live camera picture with the pupil eyecam found (green circle), where it looks for it (yellow frame), the upper and lower eyelids it found (a blue and a pink dot above and below the pupil; none on a frame where it found none; as read with "Raw", smoothed so they don't flicker otherwise), the eyelid value the camera read on that frame and "Read as closed"; the switches between the two pictures ("Eyelids", "Pupil", "Search area", all on at first) hide or show those marks. The picture is only shown, never saved (see [Privacy](#privacy)). Avatars' eyes differ, so the drawing is for checking the movement. Any tab, the cross or closing the dashboard closes it. - Basic: pause sending, where to send (three cards: VRChat directly, VRCFT (LiveLink), marked recommended, and VRCFT (FrameEyeOSC), marked all-in-one, each saying how others see your eyes (FrameEyeOSC: pupils and squint too) and what is needed on the PC. While VRChat directly is the output, a grey line under them says that if your avatar's eyes don't move, VRCFT (LiveLink or FrameEyeOSC) makes them move. Under them a small "VRCFT (ETVR)" button marked "Deprecated"; while ETVR is the output, an orange notice above it says that ETVR goes away in the next version and to pick one of the three), the eye cameras (before their setup "Not in use" and "Get started →" to the Eye cameras tab; after it "Use / Don't use" (`camera_lids`) and how they are doing, such as "L + R OK"), language (Japanese / English) with "Reset all" at its right, start with SteamVR with "Quit app" at its right. - Output: the target PC (automatic, fixed to the PC it sends to now, or typed: see below) and port. For VRChat directly also the parameter prefix, the EyeTrackingActive type, whether to send Steam Link's parameter names too and whether to move VRChat's own eyes too; for LiveLink, FrameEyeOSC and ETVR what to set up in VRCFaceTracking on the PC instead (for FrameEyeOSC four steps, with the address of the module's download page; pressing it opens the page in the Frame's browser). With LiveLink and the eye cameras, also "Send pupils straight to VRChat" (`pupils_to_vrchat`); with the eye cameras, for VRChat directly and LiveLink, "How the avatar takes pupils" (`pupil_bits`). Rows like these, which work only with the eye cameras, carry a green "Camera" badge; while the cameras aren't in use (not set up, or "Don't use"), one dashed line that opens the Eye cameras tab takes their place. - Gaze: three pages under a switch at the top (the first time "Motion", then the one you opened last, also after the panel restarts). "Motion": smoothing on or off, light / medium / strong presets and the three filter values, deadzone. "Reach": the gaze scale (how far the eyes go left, right, up and down, 5-200 %, applied after the eye fit, which never changes it; while the lock between left and right, or between up and down, is closed, both change together) and moving the eyes separately. "Glitch guard": holding the gaze while blinking, skipping unreliable gaze, removing one-sample glitches. diff --git a/panel/README.md b/panel/README.md index 61e2168..e307fcd 100644 --- a/panel/README.md +++ b/panel/README.md @@ -38,9 +38,9 @@ frameeyeosc の設定を、Steam Frame を被ったまま SteamVR のダッシ - 両目(255×170 の箱): アーモンド形の輪郭、白目、虹彩(高さの 0.27)と瞳孔(瞳孔の mm ÷ 11.8 mm × 虹彩の直径。無ければ 4 mm で描く)、虹彩はその目の視線で動く(左右 35°・上下 30° で端まで)。上まぶたは閉じ(0)でいちばん下、ふだんの開き(0.75)で開いた幅の 72%、見開き(1)でいちばん上まで上がる。細めで下まぶたが目の中ほどへ最大 45% 上がる。値が無い目は 0.75 のまま薄く描く。箱の外側の上の角に「左目」「右目」(その目についていく) - 眉: 本体が眉を送っているあいだ(`brow_from_widen` がオンで、眉を受け取る出力。データグラムのフラグ bit 2)だけ、目の上に眉の弧(#8b949e、目の幅の 22〜78%、太さは高さの 4%)。眉 0 で目のすぐ上に休み、眉 1 で目の高さの 30% 上がる(`eye_preview::browLift`)。送る値でだけ描く。生の値とカメラの値では描かない(眉はどこも測っていない。本体が見開きから作る値なので、「読んだ値」は無い)が、目の上の場所は空けたままにして、切り替えても目が動かないようにする。眉の場所の分だけ目のブロックが高くなり、映像を出していて足りないときは目を小さく(縦横の比はそのまま)描く。鏡・止めるも眉に効く。眉を送っていないときは今までとまったく同じ絵 - 目の下のチップ: 「まぶた 0.72」(送る値は VRCFT のまぶた、生の値は Valve の開き具合〔目ごとの倍率の後〕、カメラの値は eyecam のまぶた)、出どころのバッジ(送る値では本体の LidFrom: 0 = ふつうの混ぜ方なら「Valve」、それ以外〔カメラが開いている・閉じている・見開きなど〕は緑の「カメラ」。生の値はいつも Valve、カメラの値はいつもカメラ)、「瞳 6.1 mm」(送る値は送った瞳孔、カメラの値は eyecam の瞳孔、生の値と無いときは「瞳 —」)。描く値: 送る値 = 送ったまぶた・細め・瞳孔・視線、生の値 = Valve の開き具合(倍率の後、1.0 をふだんの開き 0.75 として描く)となめらかにする前の視線、細めと瞳孔は無し、カメラの値 = eyecam のまぶた・細め・瞳孔となめらかにする前の視線(カメラは視線を出さないので)。カメラの値が無い目は値なし - - カメラ映像(オンで、目のカメラを使えるとき): 目ごとに 176×176 の灰色の映像を目の下に。eyecam-rec は両目とも耳側を左(x の小さい側)にして上下を正しく書くので、右の列に描く目の映像を左右反転して、どちらの向きでも耳側をシートの外側にする(映像は描いた目についていく。鏡と正面は互いに鏡像になる)。その上に、見つけた瞳を緑の丸(#5fd38a。見つからなければ無し)、瞳を探す範囲を黄色の点線の枠(#f2d25a)、eyecam がそのフレームで測ったまぶたの線(上まぶたのふちを水色 #6cc4ff、下まぶたをピンク #ff8fc8。放物線を 16 本の線分で、暗い縁取りの上に。その線が無いフレームや、線の無い古い eyecam-rec のファイルでは描かない。「生の値」では読んだまま、ほかでは目ごとに映像の撮影時刻で、直近 3 枚の中央値のあとに one-euro フィルター〔止まっているとき 1 Hz、線が 1 px/秒動くごとに 0.05 Hz 上がる。速さは 1 Hz で〕をかけて描く。下まぶたの線は 1 枚だけ 5〜7 px 跳ねることが多く、中央値でそれが消え、開発者の録画でジグザグの約 8 割が消える。そのかわり 1 枚〔約 67 ms〕遅れ、まばたきは 2 枚目から追う。線が無い映像・0.3 秒より空いた映像・時刻が戻った映像でその線をやり直し〔前の場所から流れてこない。そこからは手元の 1〜2 枚の新しいほうを使う〕。`eye_preview::LidSmoother`)、eyecam が閉じていると読んでいるときは左上に「閉じてる判定」、左下にそのフレームで eyecam が送ったまぶたの値(「カメラ まぶた 0.62」。値が有効でなければ無し。線と同じく「生の値」以外では同じフィルター〔速さで 2.5 Hz/単位〕をかける)。映像が無い・0.5 秒より古いときは「映像を待っています」。映像はその描画の間だけメモリの上で RGB にして描き、どこにも書かない + - カメラ映像(オンで、目のカメラを使えるとき): 目ごとに 176×176 の灰色の映像を目の下に。eyecam-rec は両目とも耳側を左(x の小さい側)にして上下を正しく書くので、右の列に描く目の映像を左右反転して、どちらの向きでも耳側をシートの外側にする(映像は描いた目についていく。鏡と正面は互いに鏡像になる)。その上に、見つけた瞳を緑の丸(#5fd38a。見つからなければ無し)、瞳を探す範囲を黄色の点線の枠(#f2d25a)、eyecam がそのフレームで測ったまぶたを点 1 つずつ(上まぶたのふちを水色 #6cc4ff、下まぶたをピンク #ff8fc8。半径 4.5 px の点を暗い縁取り〔半径 6.5 px〕の上に。線の端は映像ごとに跳ねて壊れて見えるので、線全体ではなく点にする。点は eyecam が開きを測る列、つまり瞳の中心の列〔feat.rs の `y_at` は瞳の中心 `cx` での線の高さ〕で、瞳が無いフレームでは線の真ん中、どちらも線の端の列のあいだに収める。その線が無いフレームや、線の無い古い eyecam-rec のファイルでは描かない。「生の値」では読んだまま、ほかでは目ごとに映像の撮影時刻で、直近 3 枚の中央値のあとに one-euro フィルター〔止まっているとき 1 Hz、線が 1 px/秒動くごとに 0.05 Hz 上がる。速さは 1 Hz で〕をかけて描く。下まぶたの線は 1 枚だけ 5〜7 px 跳ねることが多く、中央値でそれが消え、開発者の録画でジグザグの約 8 割が消える。そのかわり 1 枚〔約 67 ms〕遅れ、まばたきは 2 枚目から追う。線が無い映像・0.3 秒より空いた映像・時刻が戻った映像でその線をやり直し〔前の場所から流れてこない。そこからは手元の 1〜2 枚の新しいほうを使う〕。`eye_preview::LidSmoother`)、eyecam が閉じていると読んでいるときは左上に「閉じてる判定」、左下にそのフレームで eyecam が送ったまぶたの値(「カメラ まぶた 0.62」。値が有効でなければ無し。点と同じく「生の値」以外では同じフィルター〔速さで 2.5 Hz/単位〕をかける)。映像が無い・0.5 秒より古いときは「映像を待っています」。2 つの映像のあいだに、印のスイッチ「まぶた」(上下の点と左下のまぶたの値)「瞳」(緑の丸)「探す範囲」(黄色の枠)を縦に並べる(映像を出しているときだけ。そこは空いているので目の大きさは変わらない。最初は全部オンで、鏡と同じくパネルが動いているあいだだけ覚える。「閉じてる判定」はいつも出す)。映像はその描画の間だけメモリの上で RGB にして描き、どこにも書かない - 使えないとき(eyecam-rec が動いていない・準備前・`camera_lids` がオフ・status.json に `preview` が無い古い eyecam-rec・`preview on` を断られた)は「カメラ映像」を押せなくし、スイッチの下に理由を 1 行(「目のカメラが動いていません」など)。最初の 3 つでは「カメラの値」も押せず、送る値で描く。目はカメラなしでも Valve の値で動く。映像が無いときは、目とチップを空いたところの真ん中に。使えなくなったら「カメラ映像」はオフに戻し、また使えるようになってももう一度押すまで映像は出さない。`preview on` を断られた(2 秒で返事が無いときも)ら理由を 5 秒(`kRefusedSec`)出してから、また押せるようにする。本体のサンプルが無い目には出どころのバッジを出さない - - いちばん下に「視線 左右 +5°・上下 −3°」(送る値は送った視線、ほかはなめらかにする前の視線。正面から見た向きでも本人から見た向き: + が右・上)と、映像を出しているときは「映像は表示するだけで保存しません。緑の丸が見つけた瞳、黄色の枠が瞳を探す範囲、水色とピンクの線が上と下のまぶた」、出していないときは「アバターの目の形は人によって違います。動きの確認用です」。本体のサンプルが 1 秒来なければ「目のデータがありません」 + - いちばん下に「視線 左右 +5°・上下 −3°」(送る値は送った視線、ほかはなめらかにする前の視線。正面から見た向きでも本人から見た向き: + が右・上)と、映像を出しているときは「映像は表示するだけで保存しません。緑の丸が見つけた瞳、黄色の枠が瞳を探す範囲、水色とピンクの点が上と下のまぶた」、出していないときは「アバターの目の形は人によって違います。動きの確認用です」。本体のサンプルが 1 秒来なければ「目のデータがありません」 - 値の受け取り方: シートが見えているあいだ(ダッシュボードが開いていて)だけ、視線の点のソケット `gaze-dots.sock` を開く(点のスイッチがオフなら点は出さない)。本体はそのソケットがあれば(1 秒に 1 回見る)サンプルごとに版 3 のデータグラム(160 バイト: 送った視線・なめらかにする前の視線と、目ごとに送ったまぶた・Valve の開き具合〔倍率の前と後〕・カメラのまぶた・送った細めと瞳孔・カメラの細めと瞳孔・LidFrom・カメラの閉じ判定・送った眉〔u16、0.8.0 から。もとは 0 の予備のバイト〕、最後に視線の倍率を掛ける前の視線。`src/dots.rs`)を送る。眉はフラグの bit 2 が立っているときだけ読む(版と大きさはそのまま: 0.7.10 までのパネルは眉を読まずにほかは同じに読み、0.7.10 までの本体のデータグラムは眉なしとして読む)。版 2(136 バイト、0.7.7 までの本体)も読む(倍率の前の視線は送った視線と同じとみなし、輪は出さない)。ほかの版は無視する - 映像の受け取り方: 映像が出せるあいだ、eyecam-rec の `ctl.sock` に `preview on` をすぐと、そのあと 1 秒ごとに送る(目のカメラタブの返事と混ざらないよう、つなぎ方は別)。出さなくなったら `preview off` を 1 回。eyecam-rec は `preview on` から 3 秒で自分で止まるので、パネルが落ちても残らない。映像は eyecam-rec のフォルダの `preview`(`tools/eyecam/README.md` の「目のプレビュー」)を読み取り専用で mmap し、seqlock で写して、magic・版・大きさを確かめる。この利用者の普通のファイルだけ、リンクはたどらない。置き換わった(inode が変わった)・消えたら開き直す - 描く間隔: 開いているあいだは、値か映像が新しくなるたびに、パネルのふだんの間隔(0.033 秒おき、約 30 回/秒)で描く。視線の点がオンでループが表示のフレームごとに回っていても、描くのは約 30 回/秒まで(`eye_preview::DrawPacer`。0.03 秒おきより詰めない)。Frame で 1 回 6.2 ms(RGBA への写しを含む。`--preview-bench`)、つまり開いているあいだ 1 コアの約 2 割。閉じたらソケットを閉じ、映像と値を捨てる diff --git a/panel/src/eye_preview.h b/panel/src/eye_preview.h index ff94fef..e462a48 100644 --- a/panel/src/eye_preview.h +++ b/panel/src/eye_preview.h @@ -27,7 +27,7 @@ enum class Mark { Lids, Pupil, Window }; /** Which of them are drawn (all at first; kept while the panel runs, like the mirror). */ struct Marks { - bool lids = true; ///< the upper and lower lid lines, and the camera's eyelid under them + bool lids = true; ///< the upper and lower lids' dots, and the camera's eyelid under them bool pupil = true; ///< the ring on the pupil found bool window = true; ///< the dashed frame the pupil is looked for in }; diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index 5736d49..dce3fac 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -71,7 +71,7 @@ UiText makeJapanese() { t.previewClosed = "閉じてる判定"; t.previewPictureLidFormat = "カメラ まぶた %.2f"; t.previewGazeFormat = "視線 左右 %+.0f°・上下 %+.0f°"; - t.previewNotePicture = "映像は表示するだけで保存しません。緑の丸が見つけた瞳、黄色の枠が瞳を探す範囲、水色とピンクの線が上と下のまぶた"; + t.previewNotePicture = "映像は表示するだけで保存しません。緑の丸が見つけた瞳、黄色の枠が瞳を探す範囲、水色とピンクの点が上と下のまぶた"; t.previewNoteShape = "アバターの目の形は人によって違います。動きの確認用です"; t.previewWaitingPicture = "映像を待っています"; t.previewCamNotRunning = "目のカメラが動いていません"; @@ -1128,7 +1128,7 @@ UiText makeEnglish() { t.previewClosed = "Read as closed"; t.previewPictureLidFormat = "Camera lid %.2f"; t.previewGazeFormat = "Gaze: %+.0f° sideways, %+.0f° up/down"; - t.previewNotePicture = "The picture is only shown, never saved. Green circle: the pupil found; yellow frame: where it is looked for; blue / pink: the upper / lower lid"; + t.previewNotePicture = "The picture is only shown, never saved. Green circle: the pupil found; yellow frame: where it is looked for; blue / pink dots: the upper / lower lid"; t.previewNoteShape = "Avatars' eyes differ in shape. This is for checking the movement"; t.previewWaitingPicture = "Waiting for the picture"; t.previewCamNotRunning = "The eye cameras aren't running"; diff --git a/panel/src/panel.cpp b/panel/src/panel.cpp index 355c2af..f6205f7 100644 --- a/panel/src/panel.cpp +++ b/panel/src/panel.cpp @@ -7567,33 +7567,30 @@ void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_pre cairo_stroke(cr); cairo_set_dash(cr, nullptr, 0, 0); } - // The lid lines eyecam-rec measured on this frame (none where it found none): the upper lid's edge (its margin, - // which the opening is measured from) and the lower lid's, each on a dark edge so it shows on bright skin too. - // Smoothed over the pictures but in Raw (the lower one jumps a few px from picture to picture) + // Where eyecam-rec found the lids on this frame (none where it found none), a dot each on its line: the upper lid's + // edge (its margin, which the opening is measured from) and the lower lid's, each on a dark edge so it shows on + // bright skin too. Dots, not the whole lines: the lines' ends jump from picture to picture while the opening + // doesn't. At the pupil's column, where eyecam-rec measures the opening (feat.rs: the lines' y_at at the pupil's + // centre); without a pupil, the line's middle. Smoothed over the pictures but in Raw (the lower one jumps a few + // px from picture to picture) const eye_preview::SmoothedLids lids = smooth ? picture.smoothed : eye_preview::SmoothedLids {picture.upper, picture.margin, picture.lower, picture.eyeLid}; - const auto lidLine = [&](const eye_preview::LidLine& line, double below, Color color) { + const auto lidDot = [&](const eye_preview::LidLine& line, double below, Color color) { if (!line.present) return; - const int steps = 16; - cairo_new_path(cr); - for (int i = 0; i <= steps; ++i) { - const double lx = line.x0 + (line.x1 - line.x0) * i / steps; - cairo_line_to(cr, px(lx), py(eye_preview::lidLineY(line, lx) + below)); - } - cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); - cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND); - pen.color(kBg, 0.55); - cairo_set_line_width(cr, 5); - cairo_stroke_preserve(cr); - pen.color(color); - cairo_set_line_width(cr, 2.5); - cairo_stroke(cr); - cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT); + const double at = picture.pupil && std::isfinite(picture.pupilX) ? picture.pupilX : (line.x0 + line.x1) / 2; + const double lx = std::clamp(at, std::min(line.x0, line.x1), std::max(line.x0, line.x1)); + const double dx = px(lx); + const double dy = py(eye_preview::lidLineY(line, lx) + below); + pen.color(kBg, 0.7); + cairo_new_sub_path(cr); + cairo_arc(cr, dx, dy, 6.5, 0, 2 * M_PI); + cairo_fill(cr); + drawDot(cr, dx, dy, 4.5, color); }; if (marks.lids && picture.lids) { - lidLine(lids.upper, std::isfinite(lids.margin) ? lids.margin : 0.0, kPreviewLidUpper); - lidLine(lids.lower, 0.0, kPreviewLidLower); + lidDot(lids.upper, std::isfinite(lids.margin) ? lids.margin : 0.0, kPreviewLidUpper); + lidDot(lids.lower, 0.0, kPreviewLidLower); } if (marks.pupil && picture.pupil) { drawRing(cr, px(picture.pupilX), py(picture.pupilY), picture.pupilR * scale, 2.5, kPreviewPupil); @@ -7607,7 +7604,7 @@ void EyePanel::drawPreviewPicture(const Pen& pen, const UiText& t, const eye_pre pen.text(x + 17, centerBaseline(by, 24, size), text, size, kText, true); }; if (picture.closed) badge(y + 8, t.previewClosed); - // The EyeLid eyecam-rec sent for this very frame, under its lines (and off with them) + // The EyeLid eyecam-rec sent for this very frame, under its dots (and off with them) if (marks.lids && picture.lids && std::isfinite(lids.eyeLid)) { char lid[64]; std::snprintf(lid, sizeof(lid), t.previewPictureLidFormat, lids.eyeLid); diff --git a/panel/src/panel.h b/panel/src/panel.h index 1caef7b..7fe127d 100644 --- a/panel/src/panel.h +++ b/panel/src/panel.h @@ -840,7 +840,7 @@ class EyePanel { /** * One eye's camera picture, upright with its temporal side toward the sheet's outside, the search window - * and the found pupil on it, the lid lines and their EyeLid, and "Read as closed"; "waiting" while there is none. + * and the found pupil on it, the lids' dots and their EyeLid, and "Read as closed"; "waiting" while there is none. * @param pen drawing tools * @param t texts * @param picture the picture diff --git a/panel/src/theme.h b/panel/src/theme.h index 48cea73..7763c2a 100644 --- a/panel/src/theme.h +++ b/panel/src/theme.h @@ -75,7 +75,7 @@ constexpr Color kEyeBrow = hexColor(0x8b949e); ///< the drawn brows (over t /** The pupil eyecam-rec found, as a circle on its picture (green), and where it looked for it (a yellow frame) */ constexpr Color kPreviewPupil = hexColor(0x5fd38a); constexpr Color kPreviewWindow = hexColor(0xf2d25a); -/** The lid lines it found: the upper lid's (light blue) and the lower lid's (pink), also told apart by their place */ +/** The lids it found, a dot each: the upper lid's (light blue) and the lower lid's (pink), also apart by their place */ constexpr Color kPreviewLidUpper = hexColor(0x6cc4ff); constexpr Color kPreviewLidLower = hexColor(0xff8fc8); // ---- The eye capture's full-view overlay (a light stimulus, not UI) ---- From 44a951d810c57fdfb9ff2e8c497c99ce7df30268 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 27 Oct 2026 19:51:33 +0900 Subject: [PATCH 62/65] =?UTF-8?q?=E7=9B=AE=E3=81=AE=E3=83=97=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC:=20=E8=8B=B1=E8=AA=9E=E3=81=AE?= =?UTF-8?q?=E6=98=A0=E5=83=8F=E3=81=AE=E4=B8=8B=E3=81=AE=E8=AA=AC=E6=98=8E?= =?UTF-8?q?=E3=82=92=E7=9F=AD=E3=81=8F=E3=81=97=E3=81=A6=E3=80=81=E3=81=84?= =?UTF-8?q?=E3=81=A1=E3=81=B0=E3=82=93=E5=B0=8F=E3=81=95=E3=81=84=E5=AD=97?= =?UTF-8?q?=E3=81=AB=E3=81=AA=E3=82=89=E3=81=AA=E3=81=84=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- panel/src/i18n.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/src/i18n.cpp b/panel/src/i18n.cpp index dce3fac..d680e5d 100644 --- a/panel/src/i18n.cpp +++ b/panel/src/i18n.cpp @@ -1128,7 +1128,7 @@ UiText makeEnglish() { t.previewClosed = "Read as closed"; t.previewPictureLidFormat = "Camera lid %.2f"; t.previewGazeFormat = "Gaze: %+.0f° sideways, %+.0f° up/down"; - t.previewNotePicture = "The picture is only shown, never saved. Green circle: the pupil found; yellow frame: where it is looked for; blue / pink dots: the upper / lower lid"; + t.previewNotePicture = "Shown only, never saved. Green: the pupil; yellow: where it's looked for; blue / pink dots: the upper / lower lid"; t.previewNoteShape = "Avatars' eyes differ in shape. This is for checking the movement"; t.previewWaitingPicture = "Waiting for the picture"; t.previewCamNotRunning = "The eye cameras aren't running"; From d0e11e44f697dd801e0f62facac2a09899bb2bff Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Tue, 27 Oct 2026 20:01:18 +0900 Subject: [PATCH 63/65] =?UTF-8?q?0.8.2-beta.5:=20=E7=89=88=E3=82=92?= =?UTF-8?q?=E4=B8=8A=E3=81=92=E3=80=81=E6=9B=B4=E6=96=B0=E5=B1=A5=E6=AD=B4?= =?UTF-8?q?=E3=82=92=E3=81=BE=E3=81=A8=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 4 +++- CHANGELOG.md | 6 +++++- Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 5633972..19c5970 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -1,6 +1,8 @@ # 更新履歴 -## Unreleased +## 0.8.2-beta.5 (2026-10-27) + +目のプレビューのカメラ映像に、目のカメラが見つけたまぶたの位置を瞳の上と下の点で出すようにしました。映像に重ねる印はスイッチでそれぞれ消せます。目の読み取り方や送る値は変わりません。 - パネル、カメラ映像を出した目のプレビュー: 目のカメラが見つけたまぶたの位置も映像に重ねるようにしました。瞳の上と下に、上まぶたのふちに水色、下まぶたにピンクの点を打ち、左下にカメラのまぶたの値(「カメラ まぶた 0.62」)を出します。まぶたが見つからなかったフレームでは、その点を描きません。点と値はちらつかないようなめらかにして描き、「生の値」では読んだままを描きます。目の読み取り方は何も変わりません - パネル、カメラ映像を出した目のプレビュー: 2 つの映像のあいだのスイッチで、映像に重ねる印をそれぞれ出したり消したりできるようにしました。「まぶた」(上と下のまぶたの点とカメラのまぶたの値)、「瞳」(緑の丸)、「探す範囲」(黄色の枠)です。パネルを起動するたびに全部オンから始まります。「閉じてる判定」はいつも出ます diff --git a/CHANGELOG.md b/CHANGELOG.md index a27f6b9..ee5960f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -## Unreleased +## 0.8.2-beta.5 (2026-10-27) + +The eye preview's camera pictures now show where the eye cameras found your eyelids, as two dots above and below the pupil, and switches let you hide each mark. How the eyes are read and sent doesn't change. + +日本語: 目のプレビューのカメラ映像に、目のカメラが見つけたまぶたの位置を瞳の上と下の点で出すようにしました。映像に重ねる印はスイッチでそれぞれ消せます。目の読み取り方や送る値は変わりません。 - Panel, eye preview with the camera picture on: the picture now also shows where the eye cameras found your eyelids, a light blue dot on the upper lid's edge and a pink one on the lower lid, above and below the pupil, and the camera's eyelid value at its bottom left ("Camera lid 0.62"). A dot is left out on frames where that lid wasn't found. The dots and the value are smoothed so they don't flicker; with "Raw" they are drawn as read. Nothing about how the eyes are read changes. - Panel, eye preview with the camera picture on: switches between the two pictures turn each mark on them on or off: "Eyelids" (both lid dots and the camera's eyelid value), "Pupil" (the green circle) and "Search area" (the yellow frame). They start on each time the panel starts. "Read as closed" always shows. diff --git a/Cargo.lock b/Cargo.lock index e01b00f..7bb709e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "frameeyeosc" -version = "0.8.2-beta.4" +version = "0.8.2-beta.5" dependencies = [ "clap", "libc", diff --git a/Cargo.toml b/Cargo.toml index 096f358..9530296 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frameeyeosc" -version = "0.8.2-beta.4" +version = "0.8.2-beta.5" edition = "2024" license = "MIT" description = "Sends Steam Frame eye tracking (gaze and eyelids) to VRChat over OSC" From b472bc9e900dc349923b6248bffe23b7cc9b6bda Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Thu, 29 Oct 2026 15:47:29 +0900 Subject: [PATCH 64/65] =?UTF-8?q?0.8.2:=20=E7=89=88=E3=82=92=E4=B8=8A?= =?UTF-8?q?=E3=81=92=E3=80=81beta.2=E3=80=9C5=20=E3=81=AE=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=B1=A5=E6=AD=B4=E3=82=92=201=20=E3=81=A4=E3=81=AB?= =?UTF-8?q?=E3=81=BE=E3=81=A8=E3=82=81=E3=81=A6=E3=80=81=E9=8C=B2=E7=94=BB?= =?UTF-8?q?=E3=82=92=E3=81=8F=E3=82=8C=E3=81=9F=E4=BA=BA=E3=81=B8=E3=81=AE?= =?UTF-8?q?=E8=AC=9D=E8=BE=9E=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 20 +++----------------- CHANGELOG.md | 28 ++++------------------------ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 9 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 19c5970..423bb9c 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -1,23 +1,13 @@ # 更新履歴 -## 0.8.2-beta.5 (2026-10-27) +## 0.8.2 (2026-10-29) -目のプレビューのカメラ映像に、目のカメラが見つけたまぶたの位置を瞳の上と下の点で出すようにしました。映像に重ねる印はスイッチでそれぞれ消せます。目の読み取り方や送る値は変わりません。 +度付きインサートを使っていても、大きく見開いた目が目のカメラで半目に読まれることがかなり減りました(そういう録画 2 本で、見開きが半目に読まれた割合が左 37 % / 右 16 % から 5 % / 4 % に)。目のプレビューのカメラ映像には、目のカメラが見つけたまぶたの位置を瞳の上と下の点で出すようにしました。SteamOS が開いた片目を閉じていると読み続けても視線が止まらなくなり(#32)、新しい「目ごとの視線」で目ごとの左右の視線を使えるようになりました。「細めの学習用に撮る」で、協力したい人だけ、目の録画を開発者に送れます。目のカメラを使っている人は、更新したら「目のカメラの校正(18秒)」をやり直してね。 - パネル、カメラ映像を出した目のプレビュー: 目のカメラが見つけたまぶたの位置も映像に重ねるようにしました。瞳の上と下に、上まぶたのふちに水色、下まぶたにピンクの点を打ち、左下にカメラのまぶたの値(「カメラ まぶた 0.62」)を出します。まぶたが見つからなかったフレームでは、その点を描きません。点と値はちらつかないようなめらかにして描き、「生の値」では読んだままを描きます。目の読み取り方は何も変わりません - パネル、カメラ映像を出した目のプレビュー: 2 つの映像のあいだのスイッチで、映像に重ねる印をそれぞれ出したり消したりできるようにしました。「まぶた」(上と下のまぶたの点とカメラのまぶたの値)、「瞳」(緑の丸)、「探す範囲」(黄色の枠)です。パネルを起動するたびに全部オンから始まります。「閉じてる判定」はいつも出ます - -## 0.8.2-beta.4 (2026-10-26) - -度付きインサートを使っていると、大きく見開いた目が目のカメラで半目に読まれることがまだありました。見開きがもっと安定して出るはずです(更新したらカメラの校正をやり直してね)。「半目の読み直し(AI)」を学習し直して、目の映像に明るい帯や明るいところがあっても、閉じや細めを取り消さないようにしました。 - - eyecam、見開き: 瞳がまるごと見えているとき、上まぶたの線が瞳の中心から短い半径の 1.2 倍(前は 0.5 倍)より下で見つかったら、瞳の上でもう一度探すようにしました。大きく見開いた目では、最初の線がぼけた瞳の上の縁(中心から半径の 1.05〜1.21 倍)に乗ることがあり、見開いた目が半目に読まれていました。度付きインサートを使っている人の録画 2 本で、見開きのフレームが半目に読まれたのは左 37.1 % / 右 15.8 % から 5.0 % / 3.6 % に、見開きが拾えたのは 59.2 % から 91.7 % になりました。閉じと細めは変わりません。開発者の録画 26 本と校正 10 本では、どの指標(半目・細め・見開き・まばたき)も変わりません。更新したら「目のカメラの校正(18秒)」をやり直してください - eyecam、「半目の読み直し(AI)」(既定はオフ): ネットの重みを、開発者の録画に白い帯・暗さ・最大 10 px のずらしを描き足して学習し直しました。前の重みは、明るい帯や明るいところがある映像で閉じた目を「開き」と読み、度付きインサートを使っている人の録画では閉じの 14 % と細めの半分を取り消していました。新しい重みでは、その録画でも、開発者の録画に絵の 35 % の帯を足したものや、暗くして帯を足したものでも取り消しません。開発者の録画で開いた目が半目に読まれるのは 2.98 % から 3.11 %(日ごとに抜いて学習した評価)になり、まばたきと細めは変わりません。オフのままなら何も変わりません - -## 0.8.2-beta.3 (2026-10-25) - -パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。また、目のカメラの校正で見開くたびに上まぶたの高さが違って読まれる人も、見開きが届きやすくなりました(更新したらカメラの校正をやり直してね)。HMD を外しているあいだに目のカメラのつかみ方が偏って、片目が遅くなったり、もう片方の目の映像が送られたりすることがあったのも直しました。 - - 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(Google ドライブなどの共有リンクを非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります - 録画は `~/eyecam/contrib/` にたまります。学習に使えるものだけを移します: `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろい、両目のフレームがあり、目ごとの raw がちょうど `frames.csv` のその目の枚数ぶんあり(ディスクがいっぱいで途中で切れたものをはじく)、フレームの 9 割以上に Valve の目のトラッキングの値がある。CSV とファイルの大きさだけで確かめ、まとめるときにも確かめ直します。使えない・途中で止まったものはわけと一緒に `aborted/` に入れて、撮り直してもらいます。隣には、いちばん新しい校正結果・ユーザー校正(`calib.json`)・eyecam の設定の写しを置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません - 片目のカメラの枚数がもう片方の 9 割に届かない録画(片方のカメラのスロットの一部だけで掴んで、1/4・2/4・3/4 になったもの)もはじき、HMD をかぶり直してから撮り直してもらいます @@ -27,11 +17,6 @@ - `frameeyeosc-panel --dump-png … --fake-contrib 状態` で、それぞれの画面を描けます - eyecam、見開き: かぶり校正の見開きの高さを、見開きの段ぜんぶの中央値ではなく、段ごとの中央値のうち一番低いものにしました(上まぶたの線が取れたフレームが 30 以上ある段だけ)。ある段だけ上まぶたが他よりかなり高く読まれると、見開きの幅がほかの段の見開きには大きすぎて、見開きが届きにくくなっていました。そういう録画(度付きインサート)では左目の幅が 0.52 から 0.25 になり、見開きの段で 0.5 以上が送られたのは 36〜39 % から 91 % になり、普段と細めの段で見開くこともありませんでした。開発者のかぶり校正 27 回では、幅は同じか少し小さくなるだけでした(中央値で 0.011)。`calib_result.json` に目ごとの `widen_step_levels`(見開きの段ごとの中央値)が増えました。効かせるには「目のカメラの校正(18秒)」をやり直してください - eyecam、目のカメラ: HMD を外しているあいだに、eyecam がカメラのスロットの一部だけでつかむことがあり、片目が本来の 1/4〜3/4 の速さで読まれたり(右目が 90 fps のうち 22.5 fps のまま、校正や録画が通っていました)、片方のカメラのスロットだけのときは右目に左目の映像が送られたりしていました。開発者の HMD では 10 日で 138 回のつかみのうち 17 回で、どれも外しているときか、かぶったその瞬間でした。両方のカメラのスロットが 4 つずつそろったときだけつかみ、1 つのカメラのスロットを絵で両目に分けることはしなくなりました。念のため、片方のカメラの枚数がもう片方の半分未満のまま 5 秒続いたらつかみ直します(同じスロットでは 1 回だけ)。カメラが完全に止まったときは今までどおりです。 - -## 0.8.2-beta.2 (2026-10-24) - -SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。度付きインサートを使っているときも、目のカメラが上まぶたを見つけやすくなるはずです。見開きが出るようになり、開いた目が細めに読まれることも減ります。 - - 視線: どちらかの目の開き具合が `gaze_hold_below`(0.5)より小さい間は、まばたきのために視線を止めています。SteamOS は開いた片目を 0.06 くらいと何分も何時間も読み続けることがあり(度付きインサートのときなど)、そのあいだ、まぶたと瞳孔は動くのに視線だけ止まっていました。今は、片目だけが線より下のときは 1 秒まで(ウインク)止め、それより長いとその目の読みは誤りとみなして止めず、もう片方の目で両目を動かします。まばたきは今までどおり止め、閉じたと読まれ続けている目があっても、まばたきのたびにまた 1 秒止まることはありません。1 サンプルだけ開いたと読まれても、1 秒はやり直しになりません - 新しい設定 `own_eye_gaze`(既定 0、0〜1。`--own-eye-gaze`)。視線 › 動く範囲の「左右の目を別々に動かす」の下の「目ごとの視線」(0〜100 %、5 % 刻み)です。「左右の目を別々に動かす」がオン(v2)のとき、目ごとの左右の視線を、2° 寄せた共通の視線(0 %、今まで通り)から、Frame が追った目ごとの左右の視線そのまま(100 %)のあいだで混ぜます。Frame の目ごとの左右の視線はぶれるので、上げるほどアバターの目がぴくつくことがあります。上下・まぶたなどはそのままで、閉じたと長く読まれている目(上)や視線が当てにならない目は、今までどおりもう片方の目の視線を使います。0 なら送るものは今までと同じです。「左右の目を別々に動かす」がオフのときと v1(もともと目ごとの視線をそのまま送ります)のときは薄く表示されます - eyecam: 急に普段の 1.3 倍より大きく写った瞳(耳側を大きく見るとカメラの方を向き、暗くなると瞳が開く)は、まばたきの直後に大きさの確認で落とされ、小さく戻るまで見失ったままでした(ある録画では 7.4 秒)。今は、そういう瞳が開いた目の瞳と同じくらい暗く欠けがなく、平たくもなっていなくて、0.3 秒見え続けたら採り、普段の大きさもそこから測り直します。録画 26 本で流し直すと、開いた目のフレームで瞳が見つかったのは 88.8 % から 89.0 %、閉じの取りこぼしは 1 分あたり 0.402 回から 0.386 回で、閉じ・細め・見開きは変わりませんでした @@ -42,6 +27,7 @@ SteamOS が開いた片目を閉じていると読み続けても、視線が止 - eyecam: HMD をかぶっていないときに目の映像をつかむと(再起動のあとなど)、目のカメラは空の HMD の絵から瞳を探す範囲や明るさを覚えてしまい、かぶったときの映像の途切れが 10 秒より短いとそれが残って、そのあとのカメラの校正で瞳が 486 枚中 5〜162 枚しか見つかりませんでした。今は、途切れた映像が戻ったとき(途切れの長さによらず)と、近接センサーが 10 秒以上「かぶっていない」と言ったあとに「かぶった」に変わったときも、かぶり直したとみなして覚え直します。ただし、まだかぶっているように見える目があるとき(その前の 10 秒の映像の 5 分の 1 以上で瞳が見えていて、虹彩の大きさがいつもに近い。センサーはかぶっていても 1〜2 秒低く読むことがあります)と、校正中は覚え直しません。最初から覚え直せば、その校正の 486 枚すべてで瞳が見つかりました - パネル: 目のカメラの校正中は、視線の合わせ直しと目合わせが始まらないようにしました。ユーザー校正の途中にほかのアプリから合わせ直しを頼まれると(frame-aux-shortcuts の 2 回タップなど)、点が出て、校正のために上を見ているときの視線の正面を保存していました。`--action recenter`・`fit`・`relearn` は 11 で終わり(1 行に「calibrating」と出ます)、その間は[目を合わせる]と[真ん中だけ合わせ直す]がグレーになり、被ったときの自動の合わせ直しは校正が終わるまで待ちます - ログ: 「Eye data has been low」の行で、取りこぼしがないときに「-0 published samples were missed」と出ていたのを、0 と出すようにしました。 +- 度付きインサートでの録画で、見開きの問題を見せてくれて、直しも確かめさせてくれた [Echo](https://x.com/EchoTheNeko) さん、ありがとう。 ## 0.8.1 (2026-10-21) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5960f..e3f1691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,15 @@ # Changelog -## 0.8.2-beta.5 (2026-10-27) +## 0.8.2 (2026-10-29) -The eye preview's camera pictures now show where the eye cameras found your eyelids, as two dots above and below the pupil, and switches let you hide each mark. How the eyes are read and sent doesn't change. +With prescription inserts, a wide-open eye is now read as half-shut far less often by the eye cameras: on two such recordings, widen frames read half-shut went from 37 % / 16 % (left / right) to 5 % / 4 %. The eye preview's camera pictures now show where the eye cameras found your eyelids, as two dots above and below the pupil. The gaze no longer stays frozen when SteamOS keeps reading one open eye as closed (#32), the new "Own gaze per eye" lets each eye's own sideways gaze through, and "Record for squint training" lets you record your eyes for the developer, only if you choose to. With the eye cameras, run "Calibrate the cameras (18 s)" again after updating. -日本語: 目のプレビューのカメラ映像に、目のカメラが見つけたまぶたの位置を瞳の上と下の点で出すようにしました。映像に重ねる印はスイッチでそれぞれ消せます。目の読み取り方や送る値は変わりません。 +日本語: 度付きインサートを使っていても、大きく見開いた目が目のカメラで半目に読まれることがかなり減りました(そういう録画 2 本で、見開きが半目に読まれた割合が左 37 % / 右 16 % から 5 % / 4 % に)。目のプレビューのカメラ映像には、目のカメラが見つけたまぶたの位置を瞳の上と下の点で出すようにしました。SteamOS が開いた片目を閉じていると読み続けても視線が止まらなくなり(#32)、新しい「目ごとの視線」で目ごとの左右の視線を使えるようになりました。「細めの学習用に撮る」で、協力したい人だけ、目の録画を開発者に送れます。目のカメラを使っている人は、更新したら「目のカメラの校正(18秒)」をやり直してね。 - Panel, eye preview with the camera picture on: the picture now also shows where the eye cameras found your eyelids, a light blue dot on the upper lid's edge and a pink one on the lower lid, above and below the pupil, and the camera's eyelid value at its bottom left ("Camera lid 0.62"). A dot is left out on frames where that lid wasn't found. The dots and the value are smoothed so they don't flicker; with "Raw" they are drawn as read. Nothing about how the eyes are read changes. - Panel, eye preview with the camera picture on: switches between the two pictures turn each mark on them on or off: "Eyelids" (both lid dots and the camera's eyelid value), "Pupil" (the green circle) and "Search area" (the yellow frame). They start on each time the panel starts. "Read as closed" always shows. - -## 0.8.2-beta.4 (2026-10-26) - -With prescription inserts, a wide-open eye could still be read as half-shut by the eye cameras; widening should now show more reliably (calibrate the cameras again after updating). The "Half-shut check (AI)" was retrained so that a bright band or patch in the eye picture no longer makes it cancel closes and squints. - -日本語: 度付きインサートを使っていると、大きく見開いた目が目のカメラで半目に読まれることがまだありました。見開きがもっと安定して出るはずです(更新したらカメラの校正をやり直してね)。「半目の読み直し(AI)」を学習し直して、目の映像に明るい帯や明るいところがあっても、閉じや細めを取り消さないようにしました。 - - eyecam, widening: with the pupil seen whole, an upper lid line found less than 1.2 times the pupil's minor radius above its centre (0.5 times before) is now searched for again above the pupil. On a wide-open eye the first line can sit on the pupil's own blurred top edge, 1.05 to 1.21 times that radius above its centre, and the widened eye read half-shut. On two recordings from someone wearing prescription inserts, widen frames read half-shut went from 37.1 % / 15.8 % (left / right) to 5.0 % / 3.6 %, and widening was caught in 91.7 % of them instead of 59.2 %; closes and squints stayed the same. On the developer's 26 recordings and 10 calibrations, every measure (half-shut, squint, widening, blinks) stayed the same. Run "Calibrate the cameras (18 s)" again after updating. - eyecam, "Half-shut check (AI)" (off by default): the network's weights were retrained on the developer's recordings with white bands, dimming and shifts of up to 10 px drawn in. The earlier weights read a closed eye as open in pictures with a bright band or patch: on one wearer with prescription inserts they cancelled 14 % of closes and half of the squints. Now they cancel none on that recording, nor on the developer's recordings with a band over 35 % of the picture or dimmed with a band. Open eyes read half-shut on the developer's recordings go from 2.98 % to 3.11 % (each day left out of training in turn); blinks and squints stay the same. With the check off, nothing changes. - -## 0.8.2-beta.3 (2026-10-25) - -New in the panel: "Record for squint training" (Advanced › Debug tools, once the eye cameras are set up) records your eyes for the developer with one press, to train the "Half-shut check (AI)" on more people's eyes. It is entirely optional: it says first what is recorded and how it is used, and records only after you agree. Widening is also easier to reach for people whose upper lid reads at a different height in each widen of the eye-camera calibration (calibrate the cameras again after updating). With the headset off, the eye cameras could also be locked onto only part of their slots, leaving one eye slow or sent the other eye's frames; that is fixed. - -日本語: パネルに「細めの学習用に撮る」を足しました(詳細 › 動作確認。目のカメラの準備が済むと使えます)。「半目の読み直し(AI)」をいろんな人の目で学習させるための録画を、ボタン 1 つで撮って開発者に送れます。協力するかは自由で、何を録ってどう使うかを先に見せて、同意したときだけ撮ります。また、目のカメラの校正で見開くたびに上まぶたの高さが違って読まれる人も、見開きが届きやすくなりました(更新したらカメラの校正をやり直してね)。HMD を外しているあいだに目のカメラのつかみ方が偏って、片目が遅くなったり、もう片方の目の映像が送られたりすることがあったのも直しました。 - - "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (a private share link from Google Drive or similar, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). - The recordings gather in `~/eyecam/contrib/`, each moved there once it can be used for training: `eye_L.raw`, `eye_R.raw`, `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` there, frames of both eyes, each raw file exactly as many frames as `frames.csv` lists for that eye (one cut short when the disk ran full is caught), and Valve's tracking values near at least 90 % of the frames; checked from the CSV files and file sizes alone, again when packing. One that can't be used, or stopped early, goes to `aborted/` with why, to be recorded again. Beside each go copies of the newest calibration result, the user calibration (`calib.json`) and eyecam's settings then. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. - A recording where one eye's camera delivered under 90 % of the other's frames (a ring locked on part of its slots: a quarter, a half or three quarters) is kept out too, asking to put the headset on again and record again. @@ -33,13 +19,6 @@ New in the panel: "Record for squint training" (Advanced › Debug tools, once t - `frameeyeosc-panel --dump-png … --fake-contrib STATE` draws each of its screens. - eyecam, widening: the wear calibration's widen level is now the lowest of the widen steps' own medians (only steps with 30 or more frames with the lid line), not the median over all of them. When one widen step read the upper lid much higher than the others, the widen step came out too large for the widening the others reached, and widening was hard to reach: on one such recording (with prescription inserts) the left eye's step went from 0.52 to 0.25, and widening went out at 0.5 or more in 91 % of the widen frames instead of 36-39 %, while the normal and squint steps stayed unwidened; on the developer's 27 wear calibrations the step stayed the same or got slightly smaller (by 0.011 in the median). `calib_result.json` now also has each eye's `widen_step_levels` (each widen step's median). Run "Calibrate the cameras (18 s)" again for it to take effect. - eyecam, eye cameras: with the headset off, eyecam could lock onto only part of the cameras' slots, so one eye was read at a quarter to three quarters of its frame rate (the right eye at 22.5 of 90 fps, through a calibration and a recording), or, with one camera's slots alone, the right eye was sent the left eye's frames. It happened in 17 of 138 locks over ten days on the developer's headset, always with the headset off or just put on. eyecam now locks only when each camera has all four of its slots, never splits one camera's slots into two eyes by their pictures, and, as a safety net, looks for the cameras again when one camera's frames come at under half the other's for 5 s (only once for the same slots). A camera that stops altogether is handled as before. - -## 0.8.2-beta.2 (2026-10-24) - -The gaze no longer stays frozen when SteamOS keeps reading one open eye as closed: after a second, the other eye moves the gaze (#32). With "Move eyes separately" on, the new "Own gaze per eye" (Gaze › Reach) lets each eye's own sideways gaze through, so looking inward with both eyes can show. The eye cameras no longer lose a pupil that suddenly looks larger, the camera eyelids no longer take over for good after many blinks on SteamOS 0.4.x, and a new user calibration now sets the squint at the same depth whatever the wear. With prescription inserts, the eye cameras should find the upper eyelid more reliably, so widening can show and an open eye is less often read as a squint. - -日本語: SteamOS が開いた片目を閉じていると読み続けても、視線が止まったままにならなくなりました。1 秒たつと、もう片方の目で視線を動かします(#32)。「左右の目を別々に動かす」がオンのとき、視線 › 動く範囲の新しい「目ごとの視線」で、目ごとの左右の視線をそのまま使えるようになりました(両目で内側を見るのも出せます)。目のカメラは、急に大きく写った瞳を見失わなくなりました。SteamOS 0.4.x で、まばたきを重ねるうちにまぶたがカメラ側に切り替わったままになることもなくなりました。新しくユーザー校正をすると、かぶるたびに細めの深さが変わることもなくなります。度付きインサートを使っているときも、目のカメラが上まぶたを見つけやすくなるはずです。見開きが出るようになり、開いた目が細めに読まれることも減ります。 - - Gaze: the gaze is held while either eye's openness is below `gaze_hold_below` (0.5), for blinks. SteamOS can read one open eye at about 0.06 for minutes to hours (with prescription inserts, for example), and the gaze stood still all that time while the eyelids and pupils kept moving. Now one eye alone below the line holds the gaze for up to 1 s (a wink); longer, and that eye's reading is taken as wrong: it no longer holds the gaze, and the other eye moves both. Blinks are held as before, and an eye read closed for good is not held again for a second after each blink. A single sample read open doesn't start the second over. - New setting `own_eye_gaze` (0 by default, 0 to 1; `--own-eye-gaze`), "Own gaze per eye" on Gaze › Reach under "Move eyes separately" (0-100 %, 5 % steps). With "Move eyes separately" on (v2), it blends each eye's sideways gaze from the shared one turned in by 2° (0 %, as before) to each eye's own sideways gaze as the Frame tracks it (100 %). The Frame's per-eye sideways gaze wobbles, so the avatar's eyes may twitch at higher values. Up/down, the eyelids and everything else stay as they are; an eye read closed for long (above) or with an unreliable gaze still takes the other's. At 0 everything sent is as before. It is greyed out while "Move eyes separately" is off, and in v1 (which already sends each eye's own gaze). - eyecam: a pupil that suddenly looks more than 1.3 times as large as usual (looking far toward your ear turns it toward the camera; a darker world dilates it) was dropped by the size check right after a blink, and stayed lost until it shrank again (7.4 s in one recording). Now, when such a pupil is as dark and as whole as the open one and no flatter, and is seen for 0.3 s, it is taken and the usual size starts over from it. Replayed on 26 recordings: the pupil found in 89.0 % of open-eye frames instead of 88.8 %, closes missed 0.386 instead of 0.402 a minute, closing, squint and widening the same. @@ -50,6 +29,7 @@ The gaze no longer stays frozen when SteamOS keeps reading one open eye as close - eyecam: when the eye cameras locked on while the headset was off (after a restart, say), they learnt the empty headset's picture (where to look for the pupil, its brightness), and kept it once the headset was put on if the video paused for less than 10 s; the next camera calibrations then found the pupil in only 5 to 162 of 486 frames. The headset now also counts as put on again when the eye video comes back after a pause, however short, and when the proximity sensor says worn after 10 s of not worn, unless an eye still looks worn (its pupil seen in at least a fifth of the last 10 s of video, at an iris size close to your usual one; the sensor can read low for a second or two while worn), and never during a calibration. A fresh start found the pupil in all 486 frames of that calibration. - Panel: while the eye cameras calibrate, the gaze re-center and the eye fit no longer start. A re-center asked for by another app (a double tap in frame-aux-shortcuts, say) during a user calibration showed its dot and saved the gaze center measured while you were looking up for the calibration. `--action recenter`, `fit` and `relearn` now exit with 11 and "calibrating" in their line, "Fit my eyes" and "Re-center only" are grey meanwhile, and the automatic re-center after putting the headset on waits for the calibration to end. - Logs: the "Eye data has been low" line said "-0 published samples were missed" when none were; it now says 0. +- Thanks to [Echo](https://x.com/EchoTheNeko) for the recordings with prescription inserts that showed the widening problem and checked the fix. ## 0.8.1 (2026-10-21) diff --git a/Cargo.lock b/Cargo.lock index 7bb709e..9f85056 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "frameeyeosc" -version = "0.8.2-beta.5" +version = "0.8.2" dependencies = [ "clap", "libc", diff --git a/Cargo.toml b/Cargo.toml index 9530296..f3b5242 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frameeyeosc" -version = "0.8.2-beta.5" +version = "0.8.2" edition = "2024" license = "MIT" description = "Sends Steam Frame eye tracking (gaze and eyelids) to VRChat over OSC" From ee8276c89fcbe34c3f6b0f7b22d426589d2f6628 Mon Sep 17 00:00:00 2001 From: sasaken1102r Date: Thu, 29 Oct 2026 20:03:19 +0900 Subject: [PATCH 65/65] =?UTF-8?q?0.8.2:=20=E6=9B=B4=E6=96=B0=E5=B1=A5?= =?UTF-8?q?=E6=AD=B4=E3=81=A7=E3=80=81=E8=A6=8B=E9=96=8B=E3=81=8D=E3=81=AE?= =?UTF-8?q?=E5=86=8D=E6=8E=A2=E7=B4=A2=E3=81=AE=E3=81=97=E3=81=8D=E3=81=84?= =?UTF-8?q?=E5=80=A4=E3=82=92=201.2=20=E5=80=8D=E3=81=AE=E6=9B=B8=E3=81=8D?= =?UTF-8?q?=E6=96=B9=E3=81=AB=E3=81=9D=E3=82=8D=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.ja.md | 4 ++-- CHANGELOG.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 423bb9c..7969955 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -6,7 +6,7 @@ - パネル、カメラ映像を出した目のプレビュー: 目のカメラが見つけたまぶたの位置も映像に重ねるようにしました。瞳の上と下に、上まぶたのふちに水色、下まぶたにピンクの点を打ち、左下にカメラのまぶたの値(「カメラ まぶた 0.62」)を出します。まぶたが見つからなかったフレームでは、その点を描きません。点と値はちらつかないようなめらかにして描き、「生の値」では読んだままを描きます。目の読み取り方は何も変わりません - パネル、カメラ映像を出した目のプレビュー: 2 つの映像のあいだのスイッチで、映像に重ねる印をそれぞれ出したり消したりできるようにしました。「まぶた」(上と下のまぶたの点とカメラのまぶたの値)、「瞳」(緑の丸)、「探す範囲」(黄色の枠)です。パネルを起動するたびに全部オンから始まります。「閉じてる判定」はいつも出ます -- eyecam、見開き: 瞳がまるごと見えているとき、上まぶたの線が瞳の中心から短い半径の 1.2 倍(前は 0.5 倍)より下で見つかったら、瞳の上でもう一度探すようにしました。大きく見開いた目では、最初の線がぼけた瞳の上の縁(中心から半径の 1.05〜1.21 倍)に乗ることがあり、見開いた目が半目に読まれていました。度付きインサートを使っている人の録画 2 本で、見開きのフレームが半目に読まれたのは左 37.1 % / 右 15.8 % から 5.0 % / 3.6 % に、見開きが拾えたのは 59.2 % から 91.7 % になりました。閉じと細めは変わりません。開発者の録画 26 本と校正 10 本では、どの指標(半目・細め・見開き・まばたき)も変わりません。更新したら「目のカメラの校正(18秒)」をやり直してください +- eyecam、見開き: 瞳がまるごと見えているとき、上まぶたの線が瞳の中心から短い半径の 1.2 倍より下で見つかったら、瞳の上でもう一度探すようにしました。大きく見開いた目では、最初の線がぼけた瞳の上の縁(中心から半径の 1.05〜1.21 倍)に乗ることがあり、見開いた目が半目に読まれていました。度付きインサートを使っている人の録画 2 本で、見開きのフレームが半目に読まれたのは左 37.1 % / 右 15.8 % から 5.0 % / 3.6 % に、見開きが拾えたのは 59.2 % から 91.7 % になりました。閉じと細めは変わりません。開発者の録画 26 本と校正 10 本では、どの指標(半目・細め・見開き・まばたき)も変わりません。更新したら「目のカメラの校正(18秒)」をやり直してください - eyecam、「半目の読み直し(AI)」(既定はオフ): ネットの重みを、開発者の録画に白い帯・暗さ・最大 10 px のずらしを描き足して学習し直しました。前の重みは、明るい帯や明るいところがある映像で閉じた目を「開き」と読み、度付きインサートを使っている人の録画では閉じの 14 % と細めの半分を取り消していました。新しい重みでは、その録画でも、開発者の録画に絵の 35 % の帯を足したものや、暗くして帯を足したものでも取り消しません。開発者の録画で開いた目が半目に読まれるのは 2.98 % から 3.11 %(日ごとに抜いて学習した評価)になり、まばたきと細めは変わりません。オフのままなら何も変わりません - 「細めの学習用に撮る」: 初めてのとき(と同意文が変わったとき)は、録るもの(目のカメラの映像。1 本 約 2.7 GB。送るまでは Frame の中だけ)、使いみち(細めと半目の見分けの調整と AI の学習だけ。アプリに入るのは学習した AI だけで映像は入らない。映像は開発者の PC にだけ置く。言えば消すが、もう配った AI からは取り出せない)、送り方(Google ドライブなどの共有リンクを非公開で。GitHub の issue や公開の場所には上げない)を見せ、眼鏡や度付きインサートを使っているかを聞きます。そのあと eyecam の新しい `protocol_contrib.txt`(約 93 秒。全体の光なし: 最初の 10 秒は普通に開けて、閉じる・まぶしいときのように細める・見開くを 3 回、上・下・左・右を見る)を 1 回撮ります - 録画は `~/eyecam/contrib/` にたまります。学習に使えるものだけを移します: `eye_L.raw`・`eye_R.raw`・`frames.csv`・`valve.csv`・`cues.csv`・`meta.txt` がそろい、両目のフレームがあり、目ごとの raw がちょうど `frames.csv` のその目の枚数ぶんあり(ディスクがいっぱいで途中で切れたものをはじく)、フレームの 9 割以上に Valve の目のトラッキングの値がある。CSV とファイルの大きさだけで確かめ、まとめるときにも確かめ直します。使えない・途中で止まったものはわけと一緒に `aborted/` に入れて、撮り直してもらいます。隣には、いちばん新しい校正結果・ユーザー校正(`calib.json`)・eyecam の設定の写しを置きます。そこの `info.txt` に 1 本ずつ節を書き足します: アプリと eyecam-rec の版、まぶたの拒否権と見開きの設定、眼鏡の答え、前の録画から何分たったか、そのあいだに HMD を外したか。個人の情報は入れません @@ -23,7 +23,7 @@ - 目のカメラを使ったまぶた(SteamOS 0.4.x): SteamOS の目のトラッキングは開いた目をカメラよりかなり低く読むことが多く、両者がそろわないまま、まばたきのたびにカメラの遅れの分だけまぶたの「疑い」が積み重なって、最後はカメラがずっとまぶたを動かすようになり、カメラが見逃したまばたきは開いたまま送られていました。今は、SteamOS が目を 1 秒開いていると読んだら疑いをやり直します。流し直しでは、右目がカメラのまぶたになっていた時間が 0.67 時間のうち 130 秒から 111 秒になり、まぶたのサンプルの 0.3〜0.6 % が変わりました - eyecam、ユーザー校正: 細めは、そのかぶりの「開いた〜閉じた」の幅に対する割合で保存されていたのに、使うときは普段の幅に掛けていました。この幅はかぶるたびに 0.6〜1.5 倍違うので、校正した細めの深さが 0.65〜1.5 倍ずれていました。今はユーザー校正で細めの深さそのものも保存し(各目のユーザーの値の `sq_depth`)、使うときもそれを、校正と同じ上限・下限の中で使います。案内に沿った「普通」の段で開いた目が細めと読まれたのは 2.4 % から 0.8 % になり、いちばん細めたときは少し低めに読まれます(0.9 以上が 53 % から 35 %)。新しくユーザー校正をしたあとから効き、前の校正は今まで通り動きます。細めが浅すぎると感じたら、ユーザー校正をやり直してください。前の版に戻してそこで校正を保存すると `sq_depth` が消えるので、更新したあとにユーザー校正をやり直してください - eyecam、度付きインサート: 目のカメラの前にインサートがあると、閉じた目が少し開いていると読まれたり、虹彩が大きすぎると測られて閉じが見えにくくなったりしていました。閉じた目で見つけた下まぶたの線が、開いていたときより虹彩の半径の 0.9 倍以上下にあるときや、探す範囲の下端に張り付いているときは使わず、最後に瞳が見えていたときの線を使います(下まぶたはまばたきでほとんど動かないため)。瞳が欠けずに見えているのに上まぶたの線が瞳の中心より下にあるときも、その線は使いません。そういうかぶりの校正のサンプルで測り直すと、左目の「開いた〜閉じた」の幅は 0.30 から 1.35 になりました(右目は 1.31)。かぶりの虹彩の半径が開いた瞳の 4 倍を超えたら、虹彩の縁をいちばん強い明るさの変わり目ではなく、虹彩から出て最初の立ち上がりで測り、半径と開いた目の iris box の明るさを覚え直します。半径を 61 / 54 px から 79 / 75 px に引っぱる帯を足した録画 26 本で流し直すと、半径は 72 / 67 px、閉じの取りこぼしは 1 分あたり 1.27 回から 1.08 回、0.2 秒未満のまばたきは 80 % から 83 % 取れるようになり、そのかわり開いた目が半目と読まれたのは 5.3 % から 6.4 % になりました。元の録画では半径の切り替えは起きず、案内に沿った閉じが取れたのは 97.3 % から 98.2 % で、ほかはほぼ同じでした(開いた目が半目と読まれたのは 5.3 % から 5.4 %)。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください -- eyecam、度付きインサートと見開き: 白目が明るく照らされていてインサートの反射もあると、上まぶたの線をその明るいところの上の縁(瞳の中心より下か、すぐ上)で取ってしまうことが多く、かぶりの校正では見開きの高さを長い上まつ毛の先で取っていました。そのため見開きの幅が普段の 3〜4 倍になって見開きに届かず、開いた目は細めに読まれていました。瞳が欠けずに見えているのに線が瞳の上半分より下にあるときは、瞳の上でもう一度探し、見つかった線が元の線と同じくらい以上はっきりしていればそちらを使います。見開きの高さを上の範囲から取るときに、その範囲で普段の段にも線があれば、その線が上がった分を見開きの幅にします。虹彩の半径の 1.1 倍を超える見開きの幅は見開きが取れなかったものとして扱い(いつもの幅を使います)、すでに保存されている 0.9 倍を超える幅は履歴の中央値に入れません。そういう人の録画で流し直すと、見開きの幅は 1.30 / 1.15 から 0.52 / 0.51 になりました。同じ録画で作ったユーザー校正を当てた(甘めの)確認では、右目は見開きの間の約 90 % で見開きと読まれ(前は 0 %)、左目は 19〜76 %(前は 0〜35 %)、普段の段の細めは 0.16〜0.63 から 0〜0.07 になりました。左目の見開いた線は虹彩の上端に乗ることが多く、見開きはまだ少し低めに出ることがあります。開発者の録画では校正は同じ値で、19 本のうち 1 本でサンプルの 0.16 % が変わりました。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください。 +- eyecam、度付きインサートと見開き: 白目が明るく照らされていてインサートの反射もあると、上まぶたの線をその明るいところの上の縁(瞳の中心より下か、すぐ上)で取ってしまうことが多く、かぶりの校正では見開きの高さを長い上まつ毛の先で取っていました。そのため見開きの幅が普段の 3〜4 倍になって見開きに届かず、開いた目は細めに読まれていました。瞳が欠けずに見えているのに線が瞳の中心に近すぎるとき(中心から短い半径の 1.2 倍より下。上の項)は、瞳の上でもう一度探し、見つかった線が元の線と同じくらい以上はっきりしていればそちらを使います。見開きの高さを上の範囲から取るときに、その範囲で普段の段にも線があれば、その線が上がった分を見開きの幅にします。虹彩の半径の 1.1 倍を超える見開きの幅は見開きが取れなかったものとして扱い(いつもの幅を使います)、すでに保存されている 0.9 倍を超える幅は履歴の中央値に入れません。そういう人の録画で流し直すと、見開きの幅は 1.30 / 1.15 から 0.52 / 0.51 になりました。同じ録画で作ったユーザー校正を当てた(甘めの)確認では、右目は見開きの間の約 90 % で見開きと読まれ(前は 0 %)、左目は 19〜76 %(前は 0〜35 %)、普段の段の細めは 0.16〜0.63 から 0〜0.07 になりました。左目の見開いた線は虹彩の上端に乗ることが多く、見開きはまだ少し低めに出ることがあります。開発者の録画では校正は同じ値で、19 本のうち 1 本でサンプルの 0.16 % が変わりました。度付きインサートを使っている人は、更新したあとに「目のカメラの校正(18秒)」をやり直してください。 - eyecam: HMD をかぶっていないときに目の映像をつかむと(再起動のあとなど)、目のカメラは空の HMD の絵から瞳を探す範囲や明るさを覚えてしまい、かぶったときの映像の途切れが 10 秒より短いとそれが残って、そのあとのカメラの校正で瞳が 486 枚中 5〜162 枚しか見つかりませんでした。今は、途切れた映像が戻ったとき(途切れの長さによらず)と、近接センサーが 10 秒以上「かぶっていない」と言ったあとに「かぶった」に変わったときも、かぶり直したとみなして覚え直します。ただし、まだかぶっているように見える目があるとき(その前の 10 秒の映像の 5 分の 1 以上で瞳が見えていて、虹彩の大きさがいつもに近い。センサーはかぶっていても 1〜2 秒低く読むことがあります)と、校正中は覚え直しません。最初から覚え直せば、その校正の 486 枚すべてで瞳が見つかりました - パネル: 目のカメラの校正中は、視線の合わせ直しと目合わせが始まらないようにしました。ユーザー校正の途中にほかのアプリから合わせ直しを頼まれると(frame-aux-shortcuts の 2 回タップなど)、点が出て、校正のために上を見ているときの視線の正面を保存していました。`--action recenter`・`fit`・`relearn` は 11 で終わり(1 行に「calibrating」と出ます)、その間は[目を合わせる]と[真ん中だけ合わせ直す]がグレーになり、被ったときの自動の合わせ直しは校正が終わるまで待ちます - ログ: 「Eye data has been low」の行で、取りこぼしがないときに「-0 published samples were missed」と出ていたのを、0 と出すようにしました。 diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f1691..eb8af55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ With prescription inserts, a wide-open eye is now read as half-shut far less oft - Panel, eye preview with the camera picture on: the picture now also shows where the eye cameras found your eyelids, a light blue dot on the upper lid's edge and a pink one on the lower lid, above and below the pupil, and the camera's eyelid value at its bottom left ("Camera lid 0.62"). A dot is left out on frames where that lid wasn't found. The dots and the value are smoothed so they don't flicker; with "Raw" they are drawn as read. Nothing about how the eyes are read changes. - Panel, eye preview with the camera picture on: switches between the two pictures turn each mark on them on or off: "Eyelids" (both lid dots and the camera's eyelid value), "Pupil" (the green circle) and "Search area" (the yellow frame). They start on each time the panel starts. "Read as closed" always shows. -- eyecam, widening: with the pupil seen whole, an upper lid line found less than 1.2 times the pupil's minor radius above its centre (0.5 times before) is now searched for again above the pupil. On a wide-open eye the first line can sit on the pupil's own blurred top edge, 1.05 to 1.21 times that radius above its centre, and the widened eye read half-shut. On two recordings from someone wearing prescription inserts, widen frames read half-shut went from 37.1 % / 15.8 % (left / right) to 5.0 % / 3.6 %, and widening was caught in 91.7 % of them instead of 59.2 %; closes and squints stayed the same. On the developer's 26 recordings and 10 calibrations, every measure (half-shut, squint, widening, blinks) stayed the same. Run "Calibrate the cameras (18 s)" again after updating. +- eyecam, widening: with the pupil seen whole, an upper lid line found less than 1.2 times the pupil's minor radius above its centre is now searched for again above the pupil. On a wide-open eye the first line can sit on the pupil's own blurred top edge, 1.05 to 1.21 times that radius above its centre, and the widened eye read half-shut. On two recordings from someone wearing prescription inserts, widen frames read half-shut went from 37.1 % / 15.8 % (left / right) to 5.0 % / 3.6 %, and widening was caught in 91.7 % of them instead of 59.2 %; closes and squints stayed the same. On the developer's 26 recordings and 10 calibrations, every measure (half-shut, squint, widening, blinks) stayed the same. Run "Calibrate the cameras (18 s)" again after updating. - eyecam, "Half-shut check (AI)" (off by default): the network's weights were retrained on the developer's recordings with white bands, dimming and shifts of up to 10 px drawn in. The earlier weights read a closed eye as open in pictures with a bright band or patch: on one wearer with prescription inserts they cancelled 14 % of closes and half of the squints. Now they cancel none on that recording, nor on the developer's recordings with a band over 35 % of the picture or dimmed with a band. Open eyes read half-shut on the developer's recordings go from 2.98 % to 3.11 % (each day left out of training in turn); blinks and squints stay the same. With the check off, nothing changes. - "Record for squint training": the first time (and when the consent's text changes) a screen says what is recorded (eye-camera video, about 2.7 GB each, staying on the Frame until you send it), how it is used (only to tune the squint and half-shut check and train its AI; only the trained AI ships in the app, never the video; kept on the developer's own PCs; deleted on request, though not out of an AI already released) and how to send it (a private share link from Google Drive or similar, never in a GitHub issue or anywhere public), then asks whether glasses or prescription inserts are worn. Then one recording of eyecam's new `protocol_contrib.txt` (about 93 s, no full-view light: 10 s relaxed, closing, squinting as in bright light and widening three times, looking up, down, left and right). - The recordings gather in `~/eyecam/contrib/`, each moved there once it can be used for training: `eye_L.raw`, `eye_R.raw`, `frames.csv`, `valve.csv`, `cues.csv` and `meta.txt` there, frames of both eyes, each raw file exactly as many frames as `frames.csv` lists for that eye (one cut short when the disk ran full is caught), and Valve's tracking values near at least 90 % of the frames; checked from the CSV files and file sizes alone, again when packing. One that can't be used, or stopped early, goes to `aborted/` with why, to be recorded again. Beside each go copies of the newest calibration result, the user calibration (`calib.json`) and eyecam's settings then. `info.txt` there gets a section for each: the app's and eyecam-rec's versions, the eyelid veto and widening settings, the answer about glasses, the minutes since the one before and whether the headset was taken off in between; no personal data. @@ -25,7 +25,7 @@ With prescription inserts, a wide-open eye is now read as half-shut far less oft - Eyelids with the eye cameras on SteamOS 0.4.x: its eye tracker often reads an open eye well below the camera, so the two never agreed and each blink's camera lag added to the eyelid's "doubt" until the camera drove the eyelid for good, and blinks the camera missed went out open. The doubt now starts over once the eye tracker has read the eye open for 1 s. In replays the right eye's time on the camera eyelid went from 130 s to 111 s in 0.67 h; 0.3-0.6 % of eyelid samples changed. - eyecam, user calibration: the squint was saved as a fraction of that wear's open-to-closed gap but used against the usual gap, which differs by 0.6 to 1.5 times from wear to wear, so the calibrated squint came out 0.65 to 1.5 times as deep. The user calibration now also saves the squint's depth (`sq_depth` in each eye's user values), and live uses it, kept within the same limits as the calibration's. Open eyes read as a squint went from 2.4 % to 0.8 % in the guided normal step; a full squint reads a little lower (0.9 or more 35 % of the time instead of 53 %). This takes effect after a new user calibration; older calibrations work as before. If your squint now reads too shallow, run the user calibration again. Going back to an older version and saving a calibration there drops `sq_depth`, so run the user calibration again after updating. - eyecam, prescription inserts: with an insert in front of the eye cameras, a closed eye could read partly open, and an iris fit far too large made closes harder to see. A lower lid line found on a closed eye 0.9 iris radii or more below where it was with the eye open, or against the bottom of its search band, is no longer taken: the last one seen with the pupil is used instead (the lower lid hardly moves in a blink), and an upper lid line below the centre of a pupil seen whole is dropped. Re-fitting one such wear calibration's samples, the left eye's open-to-closed gap goes from 0.30 to 1.35 (its right eye: 1.31). When a wear's iris radius comes out over 4 times the open pupil's, the iris edge is taken at the first rise out of the iris instead of the strongest edge, and the radius and the open eye's iris-box level are learnt again. Replayed on 26 recordings with a band added that pulls the radius from 61 / 54 px to 79 / 75 px: 72 / 67 px, closes missed 1.08 instead of 1.27 a minute, blinks under 0.2 s caught 83 % instead of 80 %, but open eyes read half-shut 6.4 % of the time instead of 5.3 %. On the original recordings the radius switch never happens; guided closes were caught 98.2 % of the time instead of 97.3 %, and everything else stayed about the same (open eyes read half-shut 5.4 % of the time instead of 5.3 %). If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. -- eyecam, prescription inserts and widening: with a brightly lit white of the eye and glints from an insert, the upper lid line was often taken at the bright area's upper edge, under or just above the pupil's centre, and the wear calibration's widen level at the tips of long upper lashes, so the widen step came out 3 to 4 times the usual size and widening could never be reached, while open eyes read as a squint. Now, when the pupil is seen whole and the line lies below the upper half of the pupil, the lid is searched for again above the pupil, and that line is taken if it is at least as clear. When the widen level comes from the higher search area and that area also has a line in the normal step, the widen step is how far that line rose. A widen step above 1.1 iris radii is taken as a widen not caught (the usual step is used), and steps above 0.9 already saved are left out of the history's median. Replaying one such wearer's recording, the widen step went from 1.30 / 1.15 to 0.52 / 0.51, and with a user calibration made from that same recording (so a favourable test) the right eye read as widened in about 90 % of its widen frames instead of none (the left eye 19 to 76 % instead of 0 to 35 %), and the squint in the normal step went from 0.16-0.63 to 0-0.07. The left eye's widened line often sits at the top of the iris, so its widening may still read a little low. On the developer's recordings, the calibrations came out the same, and 0.16 % of the samples in one of 19 recordings changed. If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. +- eyecam, prescription inserts and widening: with a brightly lit white of the eye and glints from an insert, the upper lid line was often taken at the bright area's upper edge, under or just above the pupil's centre, and the wear calibration's widen level at the tips of long upper lashes, so the widen step came out 3 to 4 times the usual size and widening could never be reached, while open eyes read as a squint. Now, when the pupil is seen whole and the line lies too close to the pupil's centre (less than 1.2 times its minor radius above it, as above), the lid is searched for again above the pupil, and that line is taken if it is at least as clear. When the widen level comes from the higher search area and that area also has a line in the normal step, the widen step is how far that line rose. A widen step above 1.1 iris radii is taken as a widen not caught (the usual step is used), and steps above 0.9 already saved are left out of the history's median. Replaying one such wearer's recording, the widen step went from 1.30 / 1.15 to 0.52 / 0.51, and with a user calibration made from that same recording (so a favourable test) the right eye read as widened in about 90 % of its widen frames instead of none (the left eye 19 to 76 % instead of 0 to 35 %), and the squint in the normal step went from 0.16-0.63 to 0-0.07. The left eye's widened line often sits at the top of the iris, so its widening may still read a little low. On the developer's recordings, the calibrations came out the same, and 0.16 % of the samples in one of 19 recordings changed. If you use a prescription insert, run "Calibrate the cameras (18 s)" again after updating. - eyecam: when the eye cameras locked on while the headset was off (after a restart, say), they learnt the empty headset's picture (where to look for the pupil, its brightness), and kept it once the headset was put on if the video paused for less than 10 s; the next camera calibrations then found the pupil in only 5 to 162 of 486 frames. The headset now also counts as put on again when the eye video comes back after a pause, however short, and when the proximity sensor says worn after 10 s of not worn, unless an eye still looks worn (its pupil seen in at least a fifth of the last 10 s of video, at an iris size close to your usual one; the sensor can read low for a second or two while worn), and never during a calibration. A fresh start found the pupil in all 486 frames of that calibration. - Panel: while the eye cameras calibrate, the gaze re-center and the eye fit no longer start. A re-center asked for by another app (a double tap in frame-aux-shortcuts, say) during a user calibration showed its dot and saved the gaze center measured while you were looking up for the calibration. `--action recenter`, `fit` and `relearn` now exit with 11 and "calibrating" in their line, "Fit my eyes" and "Re-center only" are grey meanwhile, and the automatic re-center after putting the headset on waits for the calibration to end. - Logs: the "Eye data has been low" line said "-0 published samples were missed" when none were; it now says 0.