diff --git a/Ten31Transcripts/Adapters/MeetAdapter.swift b/Ten31Transcripts/Adapters/MeetAdapter.swift index f6cfbcc..959dbaf 100644 --- a/Ten31Transcripts/Adapters/MeetAdapter.swift +++ b/Ten31Transcripts/Adapters/MeetAdapter.swift @@ -27,8 +27,11 @@ struct MeetAdapter: AppAdapter { init() { var config = GridCallAnalyzer.Config() config.nameAnchor = .bottomLeft - config.detectColoredBorder = true // Google-blue speaking ring + config.detectColoredBorder = true // Google-blue speaking ring/glow config.detectWhiteBorder = false + // The bright ring (#1a73e8) is ~0.89 sat but the lighter glow (#8ab4f8) is + // ~0.44, below the 0.5 default — lower the threshold so the glow registers. + config.colorSaturation = 0.35 config.tileExpandX = 3.0 config.tileExpandY = 5.0 self.analyzer = GridCallAnalyzer(config: config) diff --git a/Ten31Transcripts/Adapters/TeamsAdapter.swift b/Ten31Transcripts/Adapters/TeamsAdapter.swift index ca5d807..7b54e25 100644 --- a/Ten31Transcripts/Adapters/TeamsAdapter.swift +++ b/Ten31Transcripts/Adapters/TeamsAdapter.swift @@ -20,8 +20,14 @@ struct TeamsAdapter: AppAdapter { init() { var config = GridCallAnalyzer.Config() config.nameAnchor = .bottomLeft - config.detectColoredBorder = true // Teams-violet speaking ring + config.detectColoredBorder = true // Teams-violet speaking ring (faint) config.detectWhiteBorder = false + // Teams' ring is muted: brand violet #6264A7 ≈ 0.41 sat, light variants + // ~0.27 — well under the 0.5 default, so the default misses it entirely. + // Drop the threshold and (since low sat invites warm-video false positives) + // gate to the violet/indigo hue band. Both pending real-fixture calibration. + config.colorSaturation = 0.22 + config.colorHueRange = 215...275 config.tileExpandX = 3.0 config.tileExpandY = 5.0 self.analyzer = GridCallAnalyzer(config: config) diff --git a/Ten31Transcripts/Adapters/ZoomAdapter.swift b/Ten31Transcripts/Adapters/ZoomAdapter.swift index 757e507..e176840 100644 --- a/Ten31Transcripts/Adapters/ZoomAdapter.swift +++ b/Ten31Transcripts/Adapters/ZoomAdapter.swift @@ -25,8 +25,13 @@ struct ZoomAdapter: AppAdapter { init() { var config = GridCallAnalyzer.Config() config.nameAnchor = .bottomLeft - config.detectColoredBorder = true // green/yellow speaking border + config.detectColoredBorder = true // green/yellow speaking border (vivid) config.detectWhiteBorder = false + // Zoom's frame is vivid (green #2d8c3c ≈ 0.68, yellow ≈ 0.96); the green→ + // yellow hue band spans ~45–140°. Keep a generous-but-not-trivial threshold; + // require all-four-sides distribution (handled upstream) to reject bright video. + config.colorSaturation = 0.45 + config.colorHueRange = 40...150 config.tileExpandX = 3.0 config.tileExpandY = 5.0 self.analyzer = GridCallAnalyzer(config: config) diff --git a/Ten31Transcripts/Visual/FrameSampler.swift b/Ten31Transcripts/Visual/FrameSampler.swift index 30092ac..7419604 100644 --- a/Ten31Transcripts/Visual/FrameSampler.swift +++ b/Ten31Transcripts/Visual/FrameSampler.swift @@ -92,9 +92,14 @@ struct FrameSampler { return points } - /// Grid-sampled pixel positions (top-left origin) that are strongly saturated - /// AND bright enough to be a UI highlight — i.e. a coloured speaking ring/border. - func saturatedPoints(threshold: Double = 0.5, minBrightness: Double = 60, gridStep: Int = 6) -> [CGPoint] { + /// Grid-sampled pixel positions (top-left origin) that are saturated AND bright + /// enough to be a UI highlight — i.e. a coloured speaking ring/border. `threshold` + /// is the minimum saturation; lower it for muted accent rings (Teams violet, + /// Meet's light-blue glow sit below the 0.5 default). `hueRange`, when set, + /// additionally restricts to a hue band (degrees, 0…360) so a low threshold + /// doesn't pick up warm video — the per-platform calibration lever. + func saturatedPoints(threshold: Double = 0.5, minBrightness: Double = 60, + hueRange: ClosedRange? = nil, gridStep: Int = 6) -> [CGPoint] { var points: [CGPoint] = [] var y = 0 while y < height { @@ -104,11 +109,26 @@ struct FrameSampler { let r = Double(pixels[i]), g = Double(pixels[i + 1]), b = Double(pixels[i + 2]) let mx = max(r, g, b), mn = min(r, g, b) let sat = mx > 0 ? (mx - mn) / mx : 0 - if sat > threshold && mx > minBrightness { points.append(CGPoint(x: x, y: y)) } + if sat > threshold && mx > minBrightness, + hueRange == nil || hueRange!.contains(Self.hueDegrees(r, g, b, mx, mn)) { + points.append(CGPoint(x: x, y: y)) + } x += gridStep } y += gridStep } return points } + + /// HSV hue in degrees (0…360) from RGB and its precomputed max/min channels. + private static func hueDegrees(_ r: Double, _ g: Double, _ b: Double, _ mx: Double, _ mn: Double) -> Double { + let d = mx - mn + guard d > 0 else { return 0 } + let h: Double + if mx == r { h = (g - b) / d } + else if mx == g { h = 2 + (b - r) / d } + else { h = 4 + (r - g) / d } + let deg = h * 60 + return deg < 0 ? deg + 360 : deg + } } diff --git a/Ten31Transcripts/Visual/GridCallAnalyzer.swift b/Ten31Transcripts/Visual/GridCallAnalyzer.swift index 006628e..c62023a 100644 --- a/Ten31Transcripts/Visual/GridCallAnalyzer.swift +++ b/Ten31Transcripts/Visual/GridCallAnalyzer.swift @@ -27,6 +27,14 @@ struct GridCallAnalyzer { var nameAnchor: NameAnchor = .bottomCenter var detectColoredBorder = true var detectWhiteBorder = true + // Coloured-border sensitivity. Default 0.5 suits vivid rings (Zoom green/ + // yellow); lower it for muted accent rings (Teams violet ≈ 0.41, Meet's + // light-blue glow ≈ 0.44). `colorHueRange` (degrees) optionally pins the + // ring's hue so a low threshold doesn't catch warm video — set per platform + // once calibrated against real screenshots. + var colorSaturation: Double = 0.5 + var colorMinBrightness: Double = 60 + var colorHueRange: ClosedRange? = nil var minTextConfidence: Float = 0.3 var maxNameLength = 40 var minHighlightPoints = 6 @@ -58,7 +66,11 @@ struct GridCallAnalyzer { // Highlight pixels: coloured (saturated) and/or white (thin near-white). var highlight: [CGPoint] = [] - if config.detectColoredBorder { highlight += sampler.saturatedPoints() } + if config.detectColoredBorder { + highlight += sampler.saturatedPoints(threshold: config.colorSaturation, + minBrightness: config.colorMinBrightness, + hueRange: config.colorHueRange) + } if config.detectWhiteBorder { highlight += sampler.thinWhitePoints() } // Drop points inside any name-text region so the white name itself doesn't count. diff --git a/Ten31TranscriptsTests/GridCallAnalyzerTests.swift b/Ten31TranscriptsTests/GridCallAnalyzerTests.swift index 5207042..a6337d9 100644 --- a/Ten31TranscriptsTests/GridCallAnalyzerTests.swift +++ b/Ten31TranscriptsTests/GridCallAnalyzerTests.swift @@ -117,6 +117,24 @@ final class GridCallAnalyzerTests: XCTestCase { XCTAssertEqual(speaking, ["ALEX"]) } + func testTeamsDetectsFaintVioletRing() { + // Teams' brand violet #6264A7 is only ~0.41 saturation — below the 0.5 + // default that Meet/Zoom inherited. The Teams adapter lowers the threshold, + // so it must still pick the ring. + let violet = CGColor(red: 0.384, green: 0.392, blue: 0.655, alpha: 1) + let obs = TeamsAdapter().analyze(cgImage: coloredFrame(speakingIndex: 2, border: violet), at: 0) // DMITRI + let speaking = Set(obs.filter { $0.speaking }.map { $0.name }) + XCTAssertEqual(speaking, ["DMITRI"]) + } + + func testTeamsHueGateRejectsWrongColourBorder() { + // A green border (wrong hue for Teams) must NOT register — the violet hue + // gate is what keeps the lowered threshold from catching warm/other content. + let green = CGColor(red: 0.2, green: 0.85, blue: 0.3, alpha: 1) + let obs = TeamsAdapter().analyze(cgImage: coloredFrame(speakingIndex: 0, border: green), at: 0) + XCTAssertTrue(obs.filter { $0.speaking }.isEmpty) + } + func testWhiteBorderDetectorIgnoresColouredBorder() { // Signal looks only for the white border, so a coloured (Meet) border must // not register as a Signal speaker.