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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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 (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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user