Per-platform colour-border sensitivity (Teams violet, Meet glow)

Cross-platform research (Grant) flagged that the colour-border cue differs by app;
checking the real brand colours against the detector found a concrete bug: the
global 0.5 saturation threshold MISSES Teams' violet ring (#6264A7 ≈ 0.41, light
variants ~0.27) entirely and Meet's lighter blue glow (#8ab4f8 ≈ 0.44). Those
adapters would have detected nothing.

- FrameSampler.saturatedPoints: add a tunable threshold + optional hue-band gate
  (degrees) so a lowered threshold doesn't pick up warm video.
- GridCallAnalyzer.Config: colorSaturation / colorMinBrightness / colorHueRange,
  plumbed to the colour-border path (defaults preserve prior behaviour).
- MeetAdapter sat→0.35 (catch the glow); TeamsAdapter sat→0.22 + hue 215–275°
  (catch the faint violet, reject other colours); ZoomAdapter sat 0.45 + hue
  40–150° (vivid green/yellow). Values are first-pass pending real-fixture
  calibration; the hue gate is the main calibration lever.

Tests: Teams now detects the faint violet ring and rejects a green one; Meet/Zoom
vivid cases still pass. 27/27 XCTest.
This commit is contained in:
Grant Gilliam
2026-06-06 10:51:12 -05:00
parent 3785f6bdd0
commit 63cf3026ff
6 changed files with 72 additions and 8 deletions
+4 -1
View File
@@ -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)
+7 -1
View File
@@ -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)
+6 -1
View File
@@ -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 ~45140°. 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)
+24 -4
View File
@@ -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, 0360) 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<Double>? = 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 (0360) 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
}
}
+13 -1
View File
@@ -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<Double>? = 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.
@@ -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.