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() {
|
init() {
|
||||||
var config = GridCallAnalyzer.Config()
|
var config = GridCallAnalyzer.Config()
|
||||||
config.nameAnchor = .bottomLeft
|
config.nameAnchor = .bottomLeft
|
||||||
config.detectColoredBorder = true // Google-blue speaking ring
|
config.detectColoredBorder = true // Google-blue speaking ring/glow
|
||||||
config.detectWhiteBorder = false
|
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.tileExpandX = 3.0
|
||||||
config.tileExpandY = 5.0
|
config.tileExpandY = 5.0
|
||||||
self.analyzer = GridCallAnalyzer(config: config)
|
self.analyzer = GridCallAnalyzer(config: config)
|
||||||
|
|||||||
@@ -20,8 +20,14 @@ struct TeamsAdapter: AppAdapter {
|
|||||||
init() {
|
init() {
|
||||||
var config = GridCallAnalyzer.Config()
|
var config = GridCallAnalyzer.Config()
|
||||||
config.nameAnchor = .bottomLeft
|
config.nameAnchor = .bottomLeft
|
||||||
config.detectColoredBorder = true // Teams-violet speaking ring
|
config.detectColoredBorder = true // Teams-violet speaking ring (faint)
|
||||||
config.detectWhiteBorder = false
|
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.tileExpandX = 3.0
|
||||||
config.tileExpandY = 5.0
|
config.tileExpandY = 5.0
|
||||||
self.analyzer = GridCallAnalyzer(config: config)
|
self.analyzer = GridCallAnalyzer(config: config)
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ struct ZoomAdapter: AppAdapter {
|
|||||||
init() {
|
init() {
|
||||||
var config = GridCallAnalyzer.Config()
|
var config = GridCallAnalyzer.Config()
|
||||||
config.nameAnchor = .bottomLeft
|
config.nameAnchor = .bottomLeft
|
||||||
config.detectColoredBorder = true // green/yellow speaking border
|
config.detectColoredBorder = true // green/yellow speaking border (vivid)
|
||||||
config.detectWhiteBorder = false
|
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.tileExpandX = 3.0
|
||||||
config.tileExpandY = 5.0
|
config.tileExpandY = 5.0
|
||||||
self.analyzer = GridCallAnalyzer(config: config)
|
self.analyzer = GridCallAnalyzer(config: config)
|
||||||
|
|||||||
@@ -92,9 +92,14 @@ struct FrameSampler {
|
|||||||
return points
|
return points
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Grid-sampled pixel positions (top-left origin) that are strongly saturated
|
/// Grid-sampled pixel positions (top-left origin) that are saturated AND bright
|
||||||
/// AND bright enough to be a UI highlight — i.e. a coloured speaking ring/border.
|
/// enough to be a UI highlight — i.e. a coloured speaking ring/border. `threshold`
|
||||||
func saturatedPoints(threshold: Double = 0.5, minBrightness: Double = 60, gridStep: Int = 6) -> [CGPoint] {
|
/// 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 points: [CGPoint] = []
|
||||||
var y = 0
|
var y = 0
|
||||||
while y < height {
|
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 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 mx = max(r, g, b), mn = min(r, g, b)
|
||||||
let sat = mx > 0 ? (mx - mn) / mx : 0
|
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
|
x += gridStep
|
||||||
}
|
}
|
||||||
y += gridStep
|
y += gridStep
|
||||||
}
|
}
|
||||||
return points
|
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 nameAnchor: NameAnchor = .bottomCenter
|
||||||
var detectColoredBorder = true
|
var detectColoredBorder = true
|
||||||
var detectWhiteBorder = 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 minTextConfidence: Float = 0.3
|
||||||
var maxNameLength = 40
|
var maxNameLength = 40
|
||||||
var minHighlightPoints = 6
|
var minHighlightPoints = 6
|
||||||
@@ -58,7 +66,11 @@ struct GridCallAnalyzer {
|
|||||||
|
|
||||||
// Highlight pixels: coloured (saturated) and/or white (thin near-white).
|
// Highlight pixels: coloured (saturated) and/or white (thin near-white).
|
||||||
var highlight: [CGPoint] = []
|
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() }
|
if config.detectWhiteBorder { highlight += sampler.thinWhitePoints() }
|
||||||
|
|
||||||
// Drop points inside any name-text region so the white name itself doesn't count.
|
// 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"])
|
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() {
|
func testWhiteBorderDetectorIgnoresColouredBorder() {
|
||||||
// Signal looks only for the white border, so a coloured (Meet) border must
|
// Signal looks only for the white border, so a coloured (Meet) border must
|
||||||
// not register as a Signal speaker.
|
// not register as a Signal speaker.
|
||||||
|
|||||||
Reference in New Issue
Block a user