diff --git a/docker/main/build_ov_model.py b/docker/main/build_ov_model.py index 9d028159c2..f585078f3d 100644 --- a/docker/main/build_ov_model.py +++ b/docker/main/build_ov_model.py @@ -1,10 +1,14 @@ """Convert the default SSDLite MobileNet v2 model to OpenVINO IR. Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow -frontend converts the Object Detection API frozen graph natively; the four TF -outputs are then repacked into the single [1, 1, 100, 7] DetectionOutput-style -tensor that Frigate's OpenVINO detector expects, and the input is flipped to -BGR to match the legacy reverse_input_channels behavior. +frontend translates the Object Detection API pre and post processors literally, +producing per-class NonMaxSuppression, NonZero ops and map loops with data +dependent shapes that the GPU plugin handles very badly. Both are cut out the +way ssd_v2_support.json used to do it: the preprocessor is an identity at the +native 300x300 input, and the postprocessor becomes a single fused +DetectionOutput. The result is the [1, 1, 100, 7] tensor that Frigate's +OpenVINO detector expects, with the input flipped to BGR to match the legacy +reverse_input_channels behavior. """ import numpy as np @@ -12,31 +16,91 @@ import openvino as ov from openvino import opset8 as ops from openvino.preprocess import PrePostProcessor +MODEL_DIR = "/models/ssdlite_mobilenet_v2_coco_2018_05_09" +OUTPUT_PATH = "/models/ssdlite_mobilenet_v2.xml" +INPUT_SHAPE = [1, 300, 300, 3] + +# faster_rcnn_box_coder divides the deltas by pipeline.config's y/x/height/width +# scales of 10/10/5/5, which DetectionOutput expresses as per-prior variances. +BOX_VARIANCES = np.float32([0.1, 0.1, 0.2, 0.2]) + model = ov.convert_model( - "/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb", - input=[("image_tensor:0", [1, 300, 300, 3])], + f"{MODEL_DIR}/frozen_inference_graph.pb", + input=[("image_tensor:0", INPUT_SHAPE)], ) -# rows of (image_id, class_id, score, xmin, ymin, xmax, ymax) -boxes = model.output("detection_boxes:0").get_node().input_value(0) -classes = model.output("detection_classes:0").get_node().input_value(0) -scores = model.output("detection_scores:0").get_node().input_value(0) +nodes = {op.get_friendly_name(): op for op in model.get_ordered_ops()} +parameter = model.get_parameters()[0] -# (ymin,xmin,ymax,xmax) -> (xmin,ymin,xmax,ymax) -boxes = ops.gather(boxes, [1, 0, 3, 2], 2) -classes = ops.unsqueeze(classes, 2) -scores = ops.unsqueeze(scores, 2) -image_id = ops.multiply(scores, np.float32(0.0)) +preprocessor = nodes["Preprocessor/map/TensorArrayStack/TensorArrayGatherV3"] +box_deltas = nodes["Postprocessor/Reshape_1"].output(0) +class_scores = nodes["Postprocessor/convert_scores"].output(0) +anchors_output = nodes["Postprocessor/Reshape"].output(0) -detections = ops.concat([image_id, classes, scores, boxes], 2) -detections = ops.unsqueeze(detections, 1) +# The anchors only depend on the static input shape, so fold them into a +# constant and drop the generator subgraph with the rest of the postprocessor. +probe = ov.Core().compile_model( + ov.Model([anchors_output, preprocessor.output(0)], [parameter], "probe"), "CPU" +) +probe_input = np.random.default_rng(0).integers(0, 255, INPUT_SHAPE, dtype=np.uint8) +anchors, resized = (out.copy() for out in probe([probe_input]).values()) + +assert np.allclose(resized, probe_input, atol=1e-3), ( + "preprocessor is not an identity at 300x300, it cannot be bypassed" +) + +image = ops.convert(parameter, "f32") + +for consumer in list(preprocessor.output(0).get_target_inputs()): + consumer.replace_source_output(image.output(0)) + +# (ymin, xmin, ymax, xmax) -> (xmin, ymin, xmax, ymax) +priors = anchors[:, [1, 0, 3, 2]].astype(np.float32).reshape(-1) +variances = np.tile(BOX_VARIANCES, len(anchors)) +proposals = ops.constant(np.stack([priors, variances])[np.newaxis]) + +# (ty, tx, th, tw) -> (dx, dy, dw, dh) for the CENTER_SIZE decode +box_logits = ops.reshape(ops.gather(box_deltas, [1, 0, 3, 2], 1), [1, -1], False) +class_preds = ops.reshape(class_scores, [1, -1], False) + +detections = ops.detection_output( + box_logits, + class_preds, + proposals, + { + "background_label_id": 0, + "top_k": 100, + "keep_top_k": [100], + "nms_threshold": 0.6, + "confidence_threshold": 0.3, + "code_type": "caffe.PriorBoxParameter.CENTER_SIZE", + "share_location": True, + "variance_encoded_in_target": False, + "normalized": True, + "clip_before_nms": False, + "clip_after_nms": True, + "decrease_label_id": False, + }, +) detections.output(0).get_tensor().set_names({"detection_out"}) -model = ov.Model([detections], model.get_parameters(), "ssdlite_mobilenet_v2") +model = ov.Model([detections], [parameter], "ssdlite_mobilenet_v2") ppp = PrePostProcessor(model) ppp.input().tensor().set_layout(ov.Layout("NHWC")) ppp.input().preprocess().reverse_channels() model = ppp.build() -ov.save_model(model, "/models/ssdlite_mobilenet_v2.xml", compress_to_fp16=True) +# Fail the build rather than silently ship the dynamically shaped graph again. +op_types = [op.get_type_name() for op in model.get_ordered_ops()] +assert op_types.count("DetectionOutput") == 1, "postprocessor was not fused" + +for dynamic_op in ("NonMaxSuppression", "NonZero", "Loop", "TensorIterator"): + assert dynamic_op not in op_types, f"{dynamic_op} left in the graph" + +output_shape = model.outputs[0].get_partial_shape() +assert output_shape.is_static and list(output_shape) == [1, 1, 100, 7], ( + f"unexpected detector output shape {output_shape}" +) + +ov.save_model(model, OUTPUT_PATH, compress_to_fp16=True) diff --git a/frigate/api/app.py b/frigate/api/app.py index 142d4ee0e4..0af3bc1aba 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -31,7 +31,10 @@ from frigate.api.auth import ( get_allowed_cameras_for_filter, require_role, ) -from frigate.api.config_util import swap_runtime_config +from frigate.api.config_util import ( + publish_camera_section_updates, + swap_runtime_config, +) from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.request.app_body import ( AppConfigSetBody, @@ -963,6 +966,17 @@ def config_set(request: Request, body: AppConfigSetBody): body.update_topic, settings ) + # a config/cameras/* topic publishes camera copies, a + # global topic the global object. FrigateConfig.parse + # folds some global sections down into every camera, + # and workers read both objects, so any such section + # needs its camera copies sent alongside the global + # publish above. + if body.update_topic == "config/birdseye": + publish_camera_section_updates( + request.app, config, CameraConfigUpdateEnum.birdseye + ) + return JSONResponse( content=( { diff --git a/frigate/api/config_util.py b/frigate/api/config_util.py index 5d963ad725..0cb954af10 100644 --- a/frigate/api/config_util.py +++ b/frigate/api/config_util.py @@ -3,6 +3,30 @@ from fastapi import FastAPI from frigate.config import FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, +) + + +def publish_camera_section_updates( + app: FastAPI, config: FrigateConfig, update_type: CameraConfigUpdateEnum +) -> None: + """Broadcast every camera's re-resolved value for a global section. + + Global sections are folded into each camera at parse time and the camera + copies are what workers read, so send them rather than leave a worker to + guess which cameras were inheriting. + """ + for camera_name, camera_config in config.cameras.items(): + settings = getattr(camera_config, update_type.name, None) + + if settings is None: + continue + + app.config_publisher.publish_update( + CameraConfigUpdateTopic(update_type, camera_name), settings + ) def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None: @@ -16,6 +40,10 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None: camera the user turned off would silently come back on. """ app.frigate_config = config + + if app.config_holder is not None: + app.config_holder.set(config) + app.genai_manager.update_config(config) if app.profile_manager is not None: diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 5e8323b58f..8a383d41ce 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -35,6 +35,7 @@ from frigate.comms.event_metadata_updater import ( ) from frigate.config import FrigateConfig from frigate.config.camera.updater import CameraConfigUpdatePublisher +from frigate.config.holder import ConfigHolder from frigate.config.profile_manager import ProfileManager from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog from frigate.embeddings import EmbeddingsContext @@ -74,6 +75,7 @@ def create_fastapi_app( dispatcher: Dispatcher | None = None, profile_manager: ProfileManager | None = None, enforce_default_admin: bool = True, + config_holder: ConfigHolder | None = None, ): logger.info("Starting FastAPI app") app = FastAPI( @@ -162,6 +164,7 @@ def create_fastapi_app( app.replay_manager = replay_manager app.dispatcher = dispatcher app.profile_manager = profile_manager + app.config_holder = config_holder if frigate_config.auth.enabled: secret = get_jwt_secret() diff --git a/frigate/app.py b/frigate/app.py index 5a39fc8a5b..f0c0b7f9b4 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -30,6 +30,7 @@ from frigate.comms.ws import WebSocketClient from frigate.comms.zmq_proxy import ZmqProxy from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.config.config import FrigateConfig +from frigate.config.holder import ConfigHolder from frigate.config.profile_manager import ProfileManager from frigate.const import ( CACHE_DIR, @@ -122,7 +123,18 @@ class FrigateApp: self.processes: dict[str, int] = {} self.embeddings: EmbeddingsContext | None = None self.profile_manager: ProfileManager | None = None - self.config = config + self.config_holder = ConfigHolder(config) + + @property + def config(self) -> FrigateConfig: + """The current config, not the one Frigate booted with. + + Read through the holder so the deferred watchdog factories below build + a replacement process from the config as it is now. There is no setter + on purpose: a plain attribute would let a caller pin this back to a + single object and reintroduce the staleness. + """ + return self.config_holder.config def ensure_dirs(self) -> None: dirs = [ @@ -645,6 +657,7 @@ class FrigateApp: self.replay_manager, self.dispatcher, self.profile_manager, + config_holder=self.config_holder, ), host="127.0.0.1", port=5001, diff --git a/frigate/config/holder.py b/frigate/config/holder.py new file mode 100644 index 0000000000..e5f3e7bd8f --- /dev/null +++ b/frigate/config/holder.py @@ -0,0 +1,34 @@ +"""Shared handle on the config object that is current for this instance.""" + +from .config import FrigateConfig + +__all__ = ["ConfigHolder"] + + +class ConfigHolder: + """Indirection for the most recently parsed config. + + /api/config/set re-parses yaml into a brand new FrigateConfig instead of + mutating the old one, so any reference captured during startup goes stale + the first time a user saves. Anything that has to build something after + startup, most importantly the watchdog factories that rebuild a crashed + process, must read through a holder rather than close over a config + object, or the rebuilt process comes back with the config as it was at + boot and silently discards every change made since. + + There is deliberately no setter on the read side: the swap runs in exactly + one place (frigate.api.config_util.swap_runtime_config) and everyone else + only reads. + """ + + def __init__(self, config: FrigateConfig) -> None: + self._config = config + + @property + def config(self) -> FrigateConfig: + """The config as of the most recent successful save.""" + return self._config + + def set(self, config: FrigateConfig) -> None: + """Install a freshly parsed config as the current one.""" + self._config = config diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index 3aca4a8fb4..4466e2a652 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -23,6 +23,7 @@ from frigate.genai.prompts import ( build_review_summary_prompt, ) from frigate.models import Event +from frigate.util.builtin import has_non_finite_number logger = logging.getLogger(__name__) @@ -164,6 +165,15 @@ class GenAIClient: except json.JSONDecodeError as je: logger.error("Failed to parse review description JSON: %s", je) return None + + # model_construct skips validation, so non-finite numbers that + # the validated path would have rejected have to be caught here + if has_non_finite_number(raw): + logger.error( + "Discarding review description containing non-finite numbers." + ) + return None + # observations and confidence are required on the model; fill an empty default # if the response omitted it so attribute access stays safe. raw.setdefault("observations", []) diff --git a/frigate/output/output.py b/frigate/output/output.py index 67dba5221f..0793c1cd15 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -178,13 +178,10 @@ class OutputProcess(FrigateProcess): ) if update_topic is not None and birdseye_config is not None: - previous_global_mode = self.config.birdseye.mode + # only the global-only fields are applied here; the per-camera + # enabled and mode arrive on config/cameras//birdseye, + # already resolved against yaml by the config parse self.config.birdseye = birdseye_config - - for camera_config in self.config.cameras.values(): - if camera_config.birdseye.mode == previous_global_mode: - camera_config.birdseye.mode = birdseye_config.mode - logger.debug("Applied dynamic birdseye config update") # check if there is an updated config diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index 0540a50818..4e2e851f48 100644 --- a/frigate/test/http_api/test_http_config_set.py +++ b/frigate/test/http_api/test_http_config_set.py @@ -13,6 +13,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdatePublisher, CameraConfigUpdateTopic, ) +from frigate.config.holder import ConfigHolder from frigate.models import Event, Recordings, ReviewSegment from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp @@ -373,6 +374,128 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): finally: os.unlink(config_path) + @patch("frigate.api.app.find_config_file") + def test_global_birdseye_save_fans_out_resolved_camera_configs( + self, mock_find_config + ): + """A global birdseye save must also publish the per-camera values. + + Global birdseye only seeds enabled and mode; the camera copies are what + the output process actually reads. Sending just the global object makes + a worker guess which cameras were inheriting, and the only available + guess (mode still equals the previous global) wrongly claims a camera + whose explicit yaml mode happens to match. + """ + self.minimal_config["birdseye"] = {"enabled": True, "mode": "motion"} + # explicit override that matches the global value being replaced + self.minimal_config["cameras"]["front_door"]["birdseye"] = {"mode": "motion"} + + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, mock_publisher = self._create_app_with_publisher() + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"birdseye": {"mode": "continuous"}}, + "update_topic": "config/birdseye", + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + + # the global object still goes out on its own topic + mock_publisher.publisher.publish.assert_called_once() + topic, settings = mock_publisher.publisher.publish.call_args[0] + self.assertEqual(topic, "config/birdseye") + self.assertEqual(settings.mode.value, "continuous") + + published = { + call[0][0].camera: call[0][1] + for call in mock_publisher.publish_update.call_args_list + } + self.assertEqual(set(published), {"front_door", "back_yard"}) + + for call in mock_publisher.publish_update.call_args_list: + self.assertEqual( + call[0][0].update_type, CameraConfigUpdateEnum.birdseye + ) + + # the override survives, the inheriting camera follows global + self.assertEqual(published["front_door"].mode.value, "motion") + self.assertEqual(published["back_yard"].mode.value, "continuous") + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_save_updates_the_config_holder(self, mock_find_config): + """A save must move the holder onto the freshly parsed config. + + FrigateApp reads the holder when the watchdog rebuilds a crashed + process; if the save leaves it on the boot config, that process comes + back having lost every change made since Frigate started. + """ + from fastapi import Request + + from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user + from frigate.api.fastapi_app import create_fastapi_app + + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + mock_publisher = Mock(spec=CameraConfigUpdatePublisher) + mock_publisher.publisher = MagicMock() + boot_config = FrigateConfig(**self.minimal_config) + holder = ConfigHolder(boot_config) + + try: + app = create_fastapi_app( + boot_config, + self.db, + None, + None, + None, + None, + None, + None, + mock_publisher, + None, + enforce_default_admin=False, + config_holder=holder, + ) + + async def mock_get_current_user(request: Request): + return {"username": "admin", "role": "admin"} + + async def mock_get_allowed_cameras_for_filter(request: Request): + return list(self.minimal_config.get("cameras", {}).keys()) + + app.dependency_overrides[get_current_user] = mock_get_current_user + app.dependency_overrides[get_allowed_cameras_for_filter] = ( + mock_get_allowed_cameras_for_filter + ) + + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"birdseye": {"inactivity_threshold": 5}}, + "update_topic": "config/birdseye", + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + + self.assertIsNot(holder.config, boot_config) + self.assertIs(holder.config, app.frigate_config) + self.assertEqual(holder.config.birdseye.inactivity_threshold, 5) + finally: + os.unlink(config_path) + if __name__ == "__main__": unittest.main() diff --git a/frigate/test/test_config_util.py b/frigate/test/test_config_util.py index 300b0b0c51..5888f26188 100644 --- a/frigate/test/test_config_util.py +++ b/frigate/test/test_config_util.py @@ -4,6 +4,7 @@ import unittest from unittest.mock import MagicMock from frigate.api.config_util import swap_runtime_config +from frigate.config.holder import ConfigHolder class TestSwapRuntimeConfig(unittest.TestCase): @@ -12,6 +13,7 @@ class TestSwapRuntimeConfig(unittest.TestCase): def _make_app(self) -> MagicMock: app = MagicMock() app.dispatcher.comms = [MagicMock(), MagicMock()] + app.config_holder = ConfigHolder(MagicMock(name="boot_config")) return app def test_rebinds_all_references(self) -> None: @@ -37,11 +39,40 @@ class TestSwapRuntimeConfig(unittest.TestCase): # the swap rebuilds cameras from yaml, so overrides must be re-layered app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with() + def test_updates_the_config_holder(self) -> None: + app = self._make_app() + holder = app.config_holder + config = MagicMock(name="new_config") + + swap_runtime_config(app, config) + + self.assertIs(holder.config, config) + + def test_deferred_factory_builds_from_the_swapped_config(self) -> None: + """A watchdog-style factory must not rebuild from the boot config. + + The factories in FrigateApp are lambdas evaluated when a process is + restarted, long after a user may have saved. Reading through the + holder is what keeps a rebuilt process from reverting every change + made since Frigate started. + """ + app = self._make_app() + holder = app.config_holder + boot_config = holder.config + factory = lambda: holder.config # noqa: E731 + self.assertIs(factory(), boot_config) + + config = MagicMock(name="new_config") + swap_runtime_config(app, config) + + self.assertIs(factory(), config) + def test_tolerates_missing_optional_collaborators(self) -> None: app = MagicMock() app.profile_manager = None app.stats_emitter = None app.dispatcher = None + app.config_holder = None config = MagicMock(name="new_config") # must not raise when the optional collaborators are absent diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 35dba2f3a7..c1a6e787cb 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -472,6 +472,18 @@ def sanitize_float(value): return value +def has_non_finite_number(value: Any) -> bool: + """Return True if any number in a parsed JSON value is NaN or infinite.""" + if isinstance(value, float): + return not math.isfinite(value) + if isinstance(value, dict): + return any(has_non_finite_number(v) for v in value.values()) + if isinstance(value, list): + return any(has_non_finite_number(v) for v in value) + + return False + + def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: return 1 - cosine_distance(a, b) diff --git a/testing-scripts/face_dataset.py b/testing-scripts/face_dataset.py index 0c9e451d1b..cc684cbd3e 100644 --- a/testing-scripts/face_dataset.py +++ b/testing-scripts/face_dataset.py @@ -36,8 +36,8 @@ from __future__ import annotations import argparse import os import sys +from collections.abc import Iterable from dataclasses import dataclass -from typing import Iterable import cv2 import numpy as np @@ -53,21 +53,31 @@ ARCFACE_INPUT_SIZE = 112 # --------------------------------------------------------------------------- +def _bgr_to_rgb(frame: np.ndarray) -> np.ndarray: + """Mirror BaseEmbedding._bgr_to_rgb.""" + if isinstance(frame, np.ndarray) and frame.ndim == 3: + return np.ascontiguousarray(frame[:, :, ::-1]) + + return frame + + def _process_image_frigate(image: np.ndarray) -> Image.Image: """Mirror BaseEmbedding._process_image for an ndarray input. - NOTE: Frigate passes the output of `cv2.imread` (BGR) directly in. PIL's - `Image.fromarray` does NOT reorder channels, so the embedder effectively - receives a BGR-ordered tensor. We replicate that faithfully here. (Tested - — swapping to RGB produces near-identical embeddings; this model is - robust to channel order.) + `Image.fromarray` does not reorder channels, so whatever order it is + handed is what reaches the model. Callers swap to RGB first, exactly as + ArcfaceEmbedding._preprocess_inputs does. """ return Image.fromarray(image) def arcface_preprocess(image_bgr: np.ndarray) -> np.ndarray: - """Mirror ArcfaceEmbedding._preprocess_inputs.""" - pil = _process_image_frigate(image_bgr) + """Mirror ArcfaceEmbedding._preprocess_inputs. + + Face crops arrive BGR from cv2 and #23712 added the swap to RGB before + embedding, so this script has to do it too. + """ + pil = _process_image_frigate(_bgr_to_rgb(image_bgr)) width, height = pil.size if width != ARCFACE_INPUT_SIZE or height != ARCFACE_INPUT_SIZE: @@ -138,9 +148,7 @@ class LandmarkAligner: M[0, 2] += tX - eyesCenter[0] M[1, 2] += tY - eyesCenter[1] - aligned = cv2.warpAffine( - image, M, (out_w, out_h), flags=cv2.INTER_CUBIC - ) + aligned = cv2.warpAffine(image, M, (out_w, out_h), flags=cv2.INTER_CUBIC) info = dict( angle=float(angle), eye_dist_px=dist, @@ -433,9 +441,7 @@ def vector_outlier_test( if neg else np.array([]) ) - baseline_conf_neg = np.array( - [similarity_to_confidence(c) for c in baseline_neg] - ) + baseline_conf_neg = np.array([similarity_to_confidence(c) for c in baseline_neg]) print( f"\nBaseline (trim_mean only, {len(pos)} images):" @@ -465,9 +471,7 @@ def vector_outlier_test( mean, keep = iterative_mean(all_embs, T) pos_sims = np.array([cosine(p.embedding, mean) for p in pos]) neg_sims = ( - np.array([cosine(n.embedding, mean) for n in neg]) - if neg - else np.array([]) + np.array([cosine(n.embedding, mean) for n in neg]) if neg else np.array([]) ) neg_conf = np.array([similarity_to_confidence(c) for c in neg_sims]) margin = pos_sims.min() - (neg_sims.max() if len(neg_sims) else 0) @@ -483,9 +487,7 @@ def vector_outlier_test( # Show which images get dropped at the shipped threshold + neighbors for T_show in (0.25, 0.30, 0.33): _, keep = iterative_mean(all_embs, T_show) - print( - f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:" - ) + print(f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:") final_mean = stats.trim_mean(all_embs[keep], base_trim, axis=0) m_n = final_mean / (np.linalg.norm(final_mean) + 1e-9) for i, (p, k) in enumerate(zip(pos, keep)): @@ -501,9 +503,7 @@ def vector_outlier_test( ) -def degenerate_embedding_test( - pos: list[FaceSample], neg: list[FaceSample] -) -> None: +def degenerate_embedding_test(pos: list[FaceSample], neg: list[FaceSample]) -> None: """Detect whether negatives and low-quality positives share a degenerate 'tiny/noisy face' region of the embedding space. @@ -533,8 +533,7 @@ def degenerate_embedding_test( f"(how tightly negatives cluster together)" ) print( - f" pos<->pos mean cos : {np.nanmean(pp):.3f} " - f"(how tightly positives cluster)" + f" pos<->pos mean cos : {np.nanmean(pp):.3f} (how tightly positives cluster)" ) print( f" pos<->neg mean cos : {pn.mean():.3f} " @@ -558,11 +557,7 @@ def degenerate_embedding_test( neg_scores = np.array([cosine(n.embedding, clean_mean) for n in neg]) neg_confs = np.array([similarity_to_confidence(c) for c in neg_scores]) pos_scores = np.array( - [ - cosine(pos[i].embedding, clean_mean) - for i in range(len(pos)) - if keep[i] - ] + [cosine(pos[i].embedding, clean_mean) for i in range(len(pos)) if keep[i]] ) print( f"\n mean_intra >= {thresh}: keeping {int(keep.sum())}/{len(pos)} positives" @@ -585,9 +580,7 @@ def degenerate_embedding_test( ) -def contamination_analysis( - pos: list[FaceSample], neg: list[FaceSample] -) -> None: +def contamination_analysis(pos: list[FaceSample], neg: list[FaceSample]) -> None: """Check whether the positive collection contains a second identity. Two signals: @@ -617,10 +610,7 @@ def contamination_analysis( "\nPositives closer to a negative than to their own class avg" "\n(these are candidates for mislabeled images):" ) - print( - f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} " - f"{'delta':>6} name" - ) + print(f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} {'delta':>6} name") rows = list(zip(pos_names, max_to_neg, mean_to_neg, mean_intra)) rows.sort(key=lambda r: -(r[1] - r[3])) for nm, mxn, mnn, mi in rows[:15]: @@ -704,7 +694,9 @@ def main() -> int: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) - ap.add_argument("--positive", required=True, help="Training folder for one identity") + ap.add_argument( + "--positive", required=True, help="Training folder for one identity" + ) ap.add_argument( "--negative", default=None,