Repository: TheCacophonyProject/classifier-pipeline (language: python)

Issue:
Updated dbus call for trap trigger



Discussion / hints:

Fix patch:
diff --git a/.travis.yml b/.travis.yml
index 74fb4677..b2d4f88c 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -28,6 +28,7 @@ script:
   gdown.download(\"https://drive.google.com/u/1/uc?id=1CQoQLYbfX9NzRo2_BlpNE_I3c0iG6ZFd\", \"model_hq.tar\", quiet=False)"
 - mkdir model
 - tar -C model -xvf model_hq.tar
+- cp model/val_acc/metadata.txt model/val_acc/saved_model.txt
 - python3 classify.py -c tests/test-config.yaml ./tests/clips/
 - python3 piclassify.py --file tests/clips/possum.cptv  --thermal-config-file tests/config.toml
   -c tests/test-config.yaml
diff --git a/build.py b/build.py
index ee7d69ba..e677a081 100644
--- a/build.py
+++ b/build.py
@@ -27,7 +27,7 @@
 
 MIN_SAMPLES = 100
 MIN_TRACKS = 100
-LOW_SAMPLES_LABELS = ["bird/kiwi"]
+LOW_SAMPLES_LABELS = ["bird", "cat", "possum"]
 
 
 def load_config(config_file):
@@ -215,7 +215,7 @@ def split_label(dataset, label, existing_test_count=0, max_samples=None):
     min_t = MIN_TRACKS
 
     if label in LOW_SAMPLES_LABELS:
-        min_t = 1
+        min_t = 10
 
     num_validate_tracks = max(total_tracks * 0.15, min_t)
     num_test_tracks = (
@@ -367,6 +367,7 @@ def main():
     init_logging()
     args = parse_args()
     config = load_config(args.config_file)
+    logging.info("Building for type %s", config.train.type)
     test_clips = config.build.test_clips()
     if test_clips is None:
         test_clips = []
diff --git a/classifier_TEMPLATE.yaml b/classifier_TEMPLATE.yaml
index 612cb70d..8e03b600 100644
--- a/classifier_TEMPLATE.yaml
+++ b/classifier_TEMPLATE.yaml
@@ -122,7 +122,7 @@ tracking:
         dynamic_thresh: True
         camera_thresholds:
           ir:
-            camera_model: "lepton3"
+            camera_model: "ir"
             # Default temperature threshold require for tracking
             temp_thresh: None
             # Minimum raw temperature difference between background and track
diff --git a/classify/clipclassifier.py b/classify/clipclassifier.py
index f4a48658..e5c534a5 100644
--- a/classify/clipclassifier.py
+++ b/classify/clipclassifier.py
@@ -19,13 +19,6 @@
 
 from cptv import CPTVReader
 from datetime import datetime
-from ml_tools.imageprocessing import (
-    detect_objects,
-    normalize,
-    detect_objects_ir,
-    theshold_saliency,
-)
-
 from ml_tools.forestmodel import ForestModel
 
 
diff --git a/classify/thumbnail.py b/classify/thumbnail.py
index 18c1befd..73873ef2 100644
--- a/classify/thumbnail.py
+++ b/classify/thumbnail.py
@@ -68,22 +68,26 @@ def get_track_thumb_stats(clip, track):
         if region.blank or region.mass == 0:
             continue
         frame = clip.frame_buffer.get_frame(region.frame_number)
+        if frame is None:
+            continue
+        contour_image = frame.filtered if frame.mask is None else frame.mask
         contours, _ = cv2.findContours(
-            np.uint8(region.subimage(frame.mask)),
+            np.uint8(region.subimage(contour_image)),
             cv2.RETR_EXTERNAL,
             # cv2.CHAIN_APPROX_SIMPLE,
             cv2.CHAIN_APPROX_TC89_L1,
         )
         if len(contours) == 0:
             # shouldnt happen
-            contour_points.append(0)
+            # contour_points.append(0)
+            continue
         else:
             points = len(contours[0])
             if points > max_contour:
                 max_contour = points
 
         # get mask of all pixels that are considered an animal
-        filtered_sub = region.subimage(frame.mask)
+        filtered_sub = region.subimage(contour_image)
         sub_mask = filtered_sub > 0
 
         # get the thermal values for this mask
@@ -137,7 +141,7 @@ def score(stat, max_mass, max_median_diff, min_median_diff, max_contour):
     pts = stat.contours / max_contour
     pts = pts * 50
 
-    centroid_mid = tools.eucl_distance(region.centroid, region.mid) ** 0.5
+    centroid_mid = tools.eucl_distance_sq(region.centroid, region.mid) ** 0.5
     centroid_mid *= 2
     # this will be probably between 1-10 could be worth * 1.5
     # median diff out of 50
@@ -186,7 +190,7 @@ def display_track(h_data, id):
                 or region.bottom >= 119
                 or region.right >= 159
             )
-            centroid_mid = tools.eucl_distance(region.centroid, region.mid) ** 0.5
+            centroid_mid = tools.eucl_distance_sq(region.centroid, region.mid) ** 0.5
             title = f"#{region.frame_number} - centroid {round(centroid_mid)} - {is_along_border}\n"
             title += f" cts {stat.contours}"
             title += f" mass {region.mass}"
diff --git a/classify/trackprediction.py b/classify/trackprediction.py
index 939f10c6..8b0df457 100644
--- a/classify/trackprediction.py
+++ b/classify/trackprediction.py
@@ -170,7 +170,7 @@ def get_prediction(self):
         return self.description()
 
     def get_classified_footer(self, frame_number=None):
-        if len(self.smoothed_predictions) == 0:
+        if len(self.smoothed_predictions) == 0 or not self.keep_all:
             return "no classification"
         score = self.score_at_time(frame_number) * 10
         label = self.labels[self.label_at_time(frame_number)]
@@ -299,6 +299,7 @@ def label_at_time(self, frame_number, n=1):
 
             if a_min <= frame_number:
                 predictions.append(self.smoothed_predictions[i])
+
         class_best_score = np.sum(predictions, axis=0)
         if len(predictions) == 0:
             return 0
diff --git a/config/loadconfig.py b/config/loadconfig.py
index d8dccdd5..cebdd74e 100644
--- a/config/loadconfig.py
+++ b/config/loadconfig.py
@@ -71,7 +71,7 @@ def load(cls, config):
     def get_defaults(cls):
         return cls(
             enable_compression=False,
-            include_filtered_channel=False,
+            include_filtered_channel=True,
             preview=None,
             tag_precedence=LoadConfig.DEFAULT_GROUPS,
             cache_to_disk=False,
diff --git a/config/thermalconfig.py b/config/thermalconfig.py
index 6d39a740..8f9d200f 100644
--- a/config/thermalconfig.py
+++ b/config/thermalconfig.py
@@ -70,7 +70,7 @@ class CameraMotionConfig:
     edge_pixels = attr.ib()
     warmer_only = attr.ib()
     dynamic_thresh = attr.ib()
-    run_classifier = attr.ib(default=True)
+    run_classifier = attr.ib(default=False)
     bluetooth_beacons = attr.ib(default=False)
     tracking_events = attr.ib(default=False)
 
@@ -79,7 +79,7 @@ def defaults_for(cls, model):
         if model == "lepton3.5":
             return cls(
                 temp_thresh=28000,
-                delta_thresh=200,
+                delta_thresh=150,
                 count_thresh=3,
                 frame_compare_gap=45,
                 one_diff_only=True,
@@ -136,22 +136,40 @@ class RecorderConfig:
     rec_window = attr.ib()
     output_dir = attr.ib()
     disable_recordings = attr.ib()
+    min_disk_space = attr.ib()
+    constant_recorder = attr.ib()
 
     @classmethod
     def load(cls, recorder, window):
         return cls(
-            disable_recordings=recorder.get("disable_recordings", False),
-            min_secs=recorder.get("min-secs", 10),
+            constant_recorder=recorder.get("constant-recorder", False),
+            min_disk_space=recorder.get("min-disk-space-mb", 200),
+            disable_recordings=recorder.get("disable-recordings", False),
+            min_secs=recorder.get("min-secs", 5),
             max_secs=recorder.get("max-secs", 600),
             preview_secs=recorder.get("preview-secs", 5),
             rec_window=TimeWindow(
                 RelAbsTime(window.get("start-recording"), default_offset=30 * 60),
                 RelAbsTime(window.get("stop-recording"), default_offset=30 * 60),
             ),
-            output_dir=recorder.get("output-dir", "."),
+            output_dir=recorder.get("output-dir", "/var/spool/cptv"),
         )
 
 
+@attr.s
+class DeviceSetup:
+    ir = attr.ib(default=False)
+    trap_size = attr.ib(default=None)
+    # S or L for small or large
+
+    @classmethod
+    def load(cls, device):
+        size = device.get("trap-size", "L")
+        if size is not None:
+            size = size.upper()
+        return cls(ir=device.get("ir", False), trap_size=size)
+
+
 @attr.s
 class DeviceConfig:
     device_id = attr.ib()
@@ -159,7 +177,10 @@ class DeviceConfig:
 
     @classmethod
     def load(cls, device):
-        return cls(name=device.get("name"), device_id=device.get("id"))
+        return cls(
+            name=device.get("name"),
+            device_id=device.get("id"),
+        )
 
 
 @attr.s
@@ -169,6 +190,7 @@ class ThermalConfig:
     device = attr.ib()
     location = attr.ib()
     throttler = attr.ib()
+    device_setup = attr.ib()
 
     @classmethod
     def load_from_file(cls, filename=None, model=None):
@@ -189,6 +211,7 @@ def load_from_stream(cls, stream, model=None):
                 raw.get("thermal-recorder", {}), raw.get("windows", {})
             ),
             device=DeviceConfig.load(raw.get("device", {})),
+            device_setup=DeviceSetup.load(raw.get("device-setup", {})),
             location=LocationConfig.load(raw.get("location", {})),
         )
 
diff --git a/config/timewindow.py b/config/timewindow.py
index f8991fc9..33c92c86 100644
--- a/config/timewindow.py
+++ b/config/timewindow.py
@@ -2,32 +2,74 @@
 """
 from datetime import datetime, timedelta
 import logging
-
+import enum
 from astral import Location
 
 from ml_tools import tools
 
 
+class WindowStatus(enum.Enum):
+    """Types of frames"""
+
+    new = -1
+    before = 0
+    inside = 1
+    after = 2
+
+
 class TimeWindow:
     def __init__(self, start, end):
         self.start = start
         self.end = end
         self.location = None
         self.last_sunrise_check = None
+        self.non_stop = not self.use_sunrise_sunset() and self.start.dt == self.end.dt
+
+    def next_start(self):
+        return next_time(self.start)
+
+    def next_end(self):
+        return next_time(self.end)
 
     def use_sunrise_sunset(self):
         return self.start.is_relative or self.end.is_relative
 
+    def window_status(self):
+        if self.use_sunrise_sunset():
+            self.update_sun_times()
+        if self.start.is_before():
+            return WindowStatus.before
+        if self.end.is_before():
+            return WindowStatus.inside
+        return WindowStatus.after
+
+    def next_window(self):
+        if self.use_sunrise_sunset():
+            # update to tomorrows times
+            self.update_sun_times(True)
+        else:
+            self.start.dt = self.start.dt + timedelta(days=1)
+            self.end.dt = self.end.dt + timedelta(days=1)
+        logging.info(
+            "Updated to next window start %s end %s", self.start.dt, self.end.dt
+        )
+
     def inside_window(self):
         if self.use_sunrise_sunset():
             self.update_sun_times()
-            if self.end.time < self.start.time:
+            if self.end.dt < self.start.dt:
                 return self.start.is_after() or self.end.is_before()
+            elif self.start.is_after() and self.end.is_after():
+                self.next_window()
+                return False
         elif self.start.time == self.end.time:
             return True
+        if self.start.is_after() and self.end.is_after():
+            self.next_window()
+            return False
         return self.start.is_after() and self.end.is_before()
 
-    def update_sun_times(self):
+    def update_sun_times(self, next_window=False):
         if not self.use_sunrise_sunset():
             return
 
@@ -35,21 +77,45 @@ def update_sun_times(self):
             raise ValueError(
                 "Location must be set for relative times, by calling set_location"
             )
-        date = datetime.now().date()
-        if self.last_sunrise_check is None or date > self.last_sunrise_check:
-            sun_times = self.location.sun()
+        # if next_window:
+        # date = date + timedelta(days=1)
+        # after_window = self.start.is_before() or self.end.is_before()
+        # if self.last_sunrise_check is not None and (
+        #     self.start.is_before() or self.end.is_before()
+        # ):
+        if self.last_sunrise_check is None or next_window:
+            #     return
+            date = datetime.now().date()
+            if self.last_sunrise_check is not None and next_window:
+                date = self.last_sunrise_check + timedelta(days=1)
+            elif next_window:
+                date = date + timedelta(days=1)
+            sun_times = self.location.sun(date=date)
             self.last_sunrise_check = date
-
             if self.start.is_relative:
-                self.start.time = (
-                    sun_times["sunset"] + timedelta(seconds=self.start.offset_s)
-                ).time()
+                self.start.dt = sun_times["sunset"] + timedelta(
+                    seconds=self.start.offset_s
+                )
+                self.start.dt = self.start.dt.replace(tzinfo=None)
+
             if self.end.is_relative:
-                self.end.time = (
-                    sun_times["sunrise"] + timedelta(seconds=self.end.offset_s)
-                ).time()
+                self.end.dt = sun_times["sunrise"] + timedelta(
+                    seconds=self.end.offset_s
+                )
+                self.end.dt = self.end.dt.replace(tzinfo=None)
+
+                if self.end.dt < self.start.dt:
+                    date = date + timedelta(days=1)
+                    sun_times = self.location.sun(date=date)
+                    self.end.dt = sun_times["sunrise"] + timedelta(
+                        seconds=self.end.offset_s
+                    )
+                    self.end.dt = self.end.dt.replace(tzinfo=None)
+
             logging.info(
-                "start_rec is {} end_rec is {}".format(self.start.time, self.end.time)
+                "Updated sun times start is {} end is {}".format(
+                    self.start.dt, self.end.dt
+                )
             )
 
     def set_location(self, lat, lng, altitude=0):
@@ -59,12 +125,14 @@ def set_location(self, lat, lng, altitude=0):
         self.location.altitude = altitude
         self.location.timezone = tools.get_timezone_str(lat, lng)
 
+        self.update_sun_times()
+
 
 class RelAbsTime:
     def __init__(self, time_str, default_offset=None, default_time=None):
         self.is_relative = False
         self.offset_s = None
-        self.time = None
+        self.dt = None
         self.any_time = False
 
         if time_str == "" or (
@@ -74,7 +142,9 @@ def __init__(self, time_str, default_offset=None, default_time=None):
             return
 
         try:
-            self.time = datetime.strptime(time_str, "%H:%M").time()
+            self.dt = datetime.combine(
+                datetime.now(), datetime.strptime(time_str, "%H:%M").time()
+            )
         except (ValueError, TypeError):
             if not time_str:
                 self.offset_s = default_offset
@@ -84,15 +154,19 @@ def __init__(self, time_str, default_offset=None, default_time=None):
                 self.offset_s = self.parse_duration(time_str, default_offset)
 
             if self.offset_s is None and default_time:
-                self.time = default_time
+                self.dt = default_time
             else:
                 self.is_relative = True
 
+    @property
+    def time(self):
+        return self.dt.time() if self.dt is not None else None
+
     def is_after(self):
-        return self.any_time or datetime.now().time() > self.time
+        return self.any_time or datetime.now() > self.dt
 
     def is_before(self):
-        return self.any_time or datetime.now().time() < self.time
+        return self.any_time or datetime.now() < self.dt
 
     def parse_duration(self, time_str, default_offset=None):
         if not time_str:
@@ -119,3 +193,12 @@ def parse_duration(self, time_str, default_offset=None):
         except ValueError:
             pass
         return default_offset
+
+
+def next_time(rel_time):
+    if rel_time.any_time:
+        return None
+    if rel_time.is_relative:
+        return rel_time.dt
+    date = rel_time.dt
+    return date
diff --git a/config/trackingconfig.py b/config/trackingconfig.py
index 38965225..a95a8bcf 100644
--- a/config/trackingconfig.py
+++ b/config/trackingconfig.py
@@ -61,6 +61,7 @@ class TrackingConfig(DefaultConfig):
     areas_of_interest = attr.ib()
     # filter regions out by mass and variance before matching to a track
     filter_regions_pre_match = attr.ib()
+    min_hist_diff = attr.ib()
 
     @classmethod
     def load(cls, tracking):
@@ -111,6 +112,7 @@ def load_type(cls, tracking, type):
             areas_of_interest=tracking["areas_of_interest"],
             max_mass_std_percent=tracking["max_mass_std_percent"],
             filter_regions_pre_match=tracking["filter_regions_pre_match"],
+            min_hist_diff=tracking["min_hist_diff"],
         )
 
     @classmethod
@@ -173,13 +175,17 @@ def get_type_defaults(cls, type):
                 "base_velocity": 2,
             },
             filter_regions_pre_match=True,
+            min_hist_diff=None,
         )
         if type == "IR":
+            # default_tracking.min_hist_diff = 0.95
+            default_tracking.filters["min_duration_secs"] = 0
+            default_tracking.min_duration_secs = 0
             default_tracking.filter_regions_pre_match = False
             default_tracking.areas_of_interest["pixel_variance"] = 0
             default_tracking.areas_of_interest["min_mass"] = 0
-            default_tracking.filters["track_min_offset"] = 16
-            default_tracking.track_min_offset = 16
+            default_tracking.filters["track_min_offset"] = 7
+            default_tracking.track_min_offset = 20
             default_tracking.min_dimension = 10
             default_tracking.min_tracks = None
             default_tracking.frame_padding = 10
@@ -214,6 +220,22 @@ def validate(self):
     def as_dict(self):
         return attr.asdict(self)
 
+    def rescale(self, scale):
+        # adjust numbers if we rescale frame sizes
+        self.frame_padding = int(scale * self.frame_padding)
+        self.min_dimension = int(scale * self.min_dimension)
+        if self.params["base_distance_change"]:
+            self.params["base_distance_change"] *= scale
+        if self.params["min_mass_change"]:
+            self.params["min_mass_change"] *= scale
+        if self.params["max_distance"]:
+            self.params["max_distance"] *= scale
+        if self.params["base_velocity"]:
+            self.params["base_velocity"] *= scale
+        self.track_min_offset *= scale
+        self.track_min_mass *= scale
+        self.aoi_min_mass *= scale
+
 
 #
 #
diff --git a/config/trackingmotionconfig.py b/config/trackingmotionconfig.py
index 1cbcffee..12b7a2a9 100644
--- a/config/trackingmotionconfig.py
+++ b/config/trackingmotionconfig.py
@@ -46,7 +46,7 @@ def get_defaults(cls):
         thresholds["IR"] = ThresholdConfig(
             camera_model="IR",
             temp_thresh=None,
-            background_thresh=15,
+            background_thresh=12,
             default=False,
             min_temp_thresh=None,
             max_temp_thresh=None,
diff --git a/dbviewer.py b/dbviewer.py
new file mode 100644
index 00000000..8bdc7fd8
--- /dev/null
+++ b/dbviewer.py
@@ -0,0 +1,4 @@
+from dbviewer.dbviewer import main
+
+if __name__ == "__main__":
+    main()
diff --git a/load/clip.py b/load/clip.py
index 601e4c30..ac6aee7f 100644
--- a/load/clip.py
+++ b/load/clip.py
@@ -30,7 +30,7 @@
 from track.framebuffer import FrameBuffer
 from track.track import Track
 from track.region import Region
-from piclassifier.motiondetector import is_affected_by_ffc
+from piclassifier.cptvmotiondetector import is_affected_by_ffc
 
 RES_X = 160
 RES_Y = 120
@@ -53,6 +53,7 @@ def __init__(
         calc_stats=True,
         model=None,
         type="thermal",
+        fps=FRAMES_PER_SECOND,
     ):
         self._id = Clip.CLIP_ID
         Clip.CLIP_ID += 1
@@ -76,7 +77,7 @@ def __init__(
         self.res_y = None
         self.background_frames = 0
         self.config = trackconfig
-        self.frames_per_second = Clip.FRAMES_PER_SECOND
+        self.frames_per_second = fps
 
         self.calc_stats = calc_stats
         self.source_file = sourcefile
@@ -95,6 +96,21 @@ def __init__(
             self._background = background
             self._background_calculated()
 
+        self.rescaled = None
+
+    def rescaled_background(self, dims):
+        if self.rescaled is not None:
+            if self.rescaled[0] == self.current_frame:
+                logging.info("Loading from cache")
+                # 1 / 0
+                return self.rescaled[1]
+        resized = cv2.resize(
+            self.background,
+            (dims),
+        )
+        self.rescaled = (self.current_frame, resized)
+        return resized
+
     @property
     def background(self):
         return self._background
@@ -116,8 +132,9 @@ def set_motion_thresholds(self, threshold):
         self.track_max_delta = threshold.track_max_delta
 
     def _background_calculated(self):
-        self.stats.mean_background_value = np.average(self._background)
-        self.set_temp_thresh()
+        if self.type != "IR" or self.calc_stats:
+            self.stats.mean_background_value = np.average(self._background)
+            self.set_temp_thresh()
         self.background_calculated = True
 
     def on_preview(self):
@@ -464,7 +481,8 @@ def add_frame(self, thermal, filtered):
         self.frame_stats_max.append(f_max)
         self.frame_stats_median.append(f_median)
         self.frame_stats_mean.append(f_mean)
-        self.filtered_sum += np.sum(np.abs(filtered))
+        if filtered is not None:
+            self.filtered_sum += np.sum(np.abs(filtered))
 
     def completed(self, num_frames, height, width):
         if num_frames == 0:
diff --git a/load/cliploader.py b/load/cliploader.py
index 22ec3fe8..bbb097f8 100644
--- a/load/cliploader.py
+++ b/load/cliploader.py
@@ -131,18 +131,17 @@ def _export_tracks(self, full_path, clip):
                     cropped.filtered = np.zeros(cropped.thermal.shape)
                 cropped_data.append(cropped)
 
-            sample_frames = get_sample_frames(
-                clip.ffc_frames,
-                [bounds.mass for bounds in track.bounds_history],
-                self.config.build.segment_min_avg_mass,
-                cropped_data,
-            )
+            # sample_frames = get_sample_frames(
+            #     clip.ffc_frames,
+            #     [bounds.mass for bounds in track.bounds_history],
+            #     self.config.build.segment_min_avg_mass,
+            #     cropped_data,
+            # )
             try:
                 self.database.add_track(
                     clip.get_id(),
                     track,
                     cropped_data,
-                    sample_frames=sample_frames,
                     opts=self.compression,
                     original_thermal=original_thermal,
                     start_time=start_time,
@@ -221,7 +220,7 @@ def get_track_extractor(self, filename):
                 self.config.tracking,
                 self.config.load.cache_to_disk,
                 verbose=self.config.verbose,
-                do_tracking=False,
+                do_tracking=True,
             )
         return track_extractor
 
diff --git a/load/cliptrackextractor.py b/load/cliptrackextractor.py
index 3929669b..b8dea2a5 100644
--- a/load/cliptrackextractor.py
+++ b/load/cliptrackextractor.py
@@ -30,7 +30,7 @@
 from ml_tools.tools import Rectangle
 from track.region import Region
 from track.track import Track
-from piclassifier.motiondetector import is_affected_by_ffc
+from piclassifier.cptvmotiondetector import is_affected_by_ffc
 from ml_tools.imageprocessing import detect_objects, normalize
 from track.cliptracker import ClipTracker
 
@@ -125,7 +125,7 @@ def parse_clip(self, clip, process_background=False):
             for frame in reader:
                 if not process_background and frame.background_frame:
                     continue
-                self.process_frame(clip, frame.pix, is_affected_by_ffc(frame))
+                self.process_frame(clip, frame)
 
         if not clip.from_metadata and self.do_tracking:
             self.apply_track_filtering(clip)
@@ -139,17 +139,22 @@ def parse_clip(self, clip, process_background=False):
     def tracking_time(self):
         return self._tracking_time
 
-    def start_tracking(self, clip, frames):
+    def start_tracking(self, clip, frames, track_frames=True, **args):
         # no need to retrack all of preview
-        for frame in frames[-9:]:
-            self.process_frame(clip, frame.pix.copy())
+        do_tracking = self.do_tracking
+        self.do_tracking = self.do_tracking and track_frames
+        for frame in frames:
+            self.process_frame(clip, frame)
+        self.do_tracking = do_tracking
 
-    def process_frame(self, clip, thermal, ffc_affected=False):
+    def process_frame(self, clip, frame, **args):
         """
         Tracks objects through frame
         :param thermal: A numpy array of shape (height, width) and type uint16
         If specified background subtraction algorithm will be used.
         """
+        ffc_affected = is_affected_by_ffc(frame)
+        thermal = frame.pix.copy()
         if ffc_affected:
             self.print_if_verbose("{} ffc_affected".format(clip.current_frame))
         clip.ffc_affected = ffc_affected
diff --git a/load/irtrackextractor.py b/load/irtrackextractor.py
index 6db337b3..8ebb717b 100644
--- a/load/irtrackextractor.py
+++ b/load/irtrackextractor.py
@@ -29,20 +29,18 @@
 import cv2
 
 from .clip import Clip
-from ml_tools.tools import Rectangle
+from ml_tools.tools import Rectangle, eucl_distance_sq
 from track.region import Region
 from track.track import Track
-from piclassifier.motiondetector import is_affected_by_ffc
 from ml_tools.imageprocessing import (
     detect_objects,
     normalize,
     detect_objects_ir,
-    theshold_saliency,
-    detect_objects_both,
 )
-from track.cliptracker import ClipTracker
+from track.cliptracker import ClipTracker, CVBackground, DiffBackground
 
-DO_SALIENCY = True
+DO_SALIENCY = False
+DEBUG_TRAP = False
 
 
 class Line:
@@ -50,18 +48,25 @@ def __init__(self, m, c):
         self.m = m
         self.c = c
 
+    # check point is above live
     def is_above(self, point):
         y_value = self.y_res(point[0])
         if point[1] > y_value:
             return True
         return False
 
+    # check point is below line
+    def is_below(self, point):
+        return not self.is_above(point)
+
+    # check point is to the left of line
     def is_left(self, point):
         x_value = self.x_res(point[1])
         if point[0] < x_value:
             return True
         return False
 
+    # check point is to the right of line
     def is_right(self, point):
         return not self.is_left(point)
 
@@ -72,16 +77,41 @@ def x_res(self, y):
         return (y - self.c) / self.m
         # return x * self.m + self.c
 
+    def __str__(self):
+        return f"y={self.m}x + {self.c}"
+
+
+def get_trap_lines(trap_size):
+    if trap_size == "S":
+        lb = Line(1.3, 297.5)
+        rb = Line(-1.4, 1148)
+    else:
+        # just default to large for now
+        lb = Line(1.28, 180)
+        rb = Line(-1.2, 979)
+    logging.info(
+        "Getting trap lines for trap size %s left bottom %s right bottom %s",
+        trap_size,
+        lb,
+        rb,
+    )
+    return lb, rb
+
 
 class IRTrackExtractor(ClipTracker):
     PREVIEW = "preview"
     VERSION = 10
     TYPE = "IR"
-
-    LEFT_BOTTOM = Line(5 / 14, 160)
-    RIGHT_BOTTOM = Line(-5 / 12, 421.7)
-    # BACK_TOP = Line(0, 250)
-    # BACK_BOTTOM = Line(0, 170)
+    # # new perspective 19-10-2022
+    # LEFT_BOTTOM = Line(1.28, 180)
+    # RIGHT_BOTTOM = Line(-1.2, 979)
+    #
+    # LEFT_BOTTOM = Line(1.3, 297.5)
+    #
+    # RIGHT_BOTTOM = Line(-1.4, 1148)
+    # old perspective
+    # LEFT_BOTTOM = Line(5 / 14, 160)
+    # RIGHT_BOTTOM = Line(-5 / 12, 421.7)
 
     @property
     def tracker_version(self):
@@ -106,6 +136,9 @@ def __init__(
         verbose=False,
         scale=None,
         do_tracking=True,
+        on_trapped=None,
+        update_background=True,
+        trap_size="L",
     ):
         super().__init__(
             config,
@@ -114,13 +147,18 @@ def __init__(
             calc_stats,
             verbose,
             do_tracking=do_tracking,
+            scale=scale,
         )
-        self.scale = scale
+        self.on_trapped = on_trapped
         self.saliency = None
-        if self.scale:
-            self.frame_padding = int(scale * self.frame_padding)
-            self.min_dimension = int(scale * self.min_dimension)
         self.background = None
+        self.res_x = None
+        self.res_y = None
+        # if false background is being updated else where i.e motion detector
+        self.update_background = update_background
+        self.trap_size = trap_size
+        self.left_bottom, self.right_bottom = get_trap_lines(self.trap_size)
+        self.learning_rate = -1
 
     def parse_clip(self, clip, process_background=False):
         """
@@ -140,56 +178,122 @@ def parse_clip(self, clip, process_background=False):
         )
 
         _, ext = os.path.splitext(clip.source_file)
-        count = 0
         background = None
-        vidcap = cv2.VideoCapture(clip.source_file)
+        vidcap = cv2.VideoCapture(str(clip.source_file))
+        fail_count = 0
+
         while True:
             success, image = vidcap.read()
+
             if not success:
+                if fail_count < 1:
+                    fail_count += 1
+                    # try once more if its first fail as the mp4s from pi have errors at key frames
+                    continue
                 break
+            fail_count = 0
+
             gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
-            if clip.current_frame == -1:
-                background = np.uint32(gray)
-                self.init_saliency(gray.shape[1], gray.shape[0])
+            if background is None:
+                self.res_x = gray.shape[0]
+                self.res_y = gray.shape[1]
                 clip.set_res(gray.shape[1], gray.shape[0])
-                clip.set_model("IR")
-                clip.set_video_stats(datetime.now())
-                self.background = Background(gray)
-                clip.set_background(background)
+                if clip.from_metadata:
+                    for track in clip.tracks:
+                        track.crop_regions()
+
+                background = np.uint8(gray)
+                # cv2.imshow("bak", np.uint8(background))
+                # cv2.waitKey(1000)
+                # if this is grey scale it is the tracking backgorund frame
+                is_background_frame = np.all(
+                    image[:, :, 0] == image[:, :, 1]
+                ) and np.all(image[:, :, 1] == image[:, :, 2])
+                background_frames = 500 if is_background_frame else 1
+                self.start_tracking(
+                    clip, background_frame=gray, background_frames=background_frames
+                )
+                if is_background_frame:
+                    continue
             self.process_frame(clip, gray)
-        vidcap.release()
 
+        vidcap.release()
         if not clip.from_metadata and self.do_tracking:
             self.apply_track_filtering(clip)
 
         if self.calc_stats:
             clip.stats.completed(clip.current_frame, clip.res_y, clip.res_x)
+
         self._tracking_time = time.time() - start
         return True
 
-    def start_tracking(self, clip, frames):
-        if len(frames) == 0:
-            return
-        res_x = clip.res_x
-        res_y = clip.res_y
+    def start_tracking(
+        self,
+        clip,
+        frames=None,
+        track_frames=-1,
+        background_alg=None,
+        background_frame=None,
+        background_frames=1,
+        retrack_back=True,
+    ):
+        # track frames is the number of frames to track from supplied frames (most recent first)
+        # -1 means all, 0 means none, 1 means just last frame etc
+        self.res_x = clip.res_x
+        self.res_y = clip.res_y
+        if DO_SALIENCY:
+            self.init_saliency()
+        clip.set_model("IR")
+        clip.set_video_stats(datetime.now())
+        if background_alg is None:
+            self.background = CVBackground(use_subsense=False)
+            # self.diff_background = DiffBackground(clip.background_thresh)
+            if background_frame is not None:
+                # if self.scale:
+                #     logging.info("Rescaling back")
+                #     background_frame = cv2.resize(
+                #         background_frame,
+                #         (int(self.res_x * self.scale), int(self.res_y * self.scale)),
+                #         interpolation=cv2.INTER_AREA,
+                #     )
+
+                self.background.set_background(background_frame, background_frames)
+                # self.diff_background.set_background(background, background_frames)
+        else:
+            self.background = background_alg
+        self.init_saliency()
+        if frames is not None:
+            do_tracking = self.do_tracking
+            update_background = self.update_background
+            remaining = len(frames)
+            for i, frame in enumerate(frames):
+                self.do_tracking = do_tracking and (
+                    (track_frames == -1) or (remaining <= track_frames)
+                )
+                self.learning_rate = 0
+                self.update_background = self.do_tracking and retrack_back
+                self.process_frame(clip, frame)
+                remaining -= 1
+            self.learning_rate = -1
+            self.update_background = update_background
+            self.do_tracking = do_tracking
+
+    def init_saliency(self):
+        res_x = self.res_x
+        res_y = self.res_y
         if self.scale is not None:
             res_x = int(self.res_x * self.scale)
             res_y = int(self.res_y * self.scale)
-
-            # clip.resized_background = cv2.resize(clip.background, self.resize_dims)
-            # logging.info("Resizing backgorund %s", clip.tracking_background.shape)
-        self.init_saliency(res_x, res_y)
-        for frame in frames[-9:]:
-            self.process_frame(clip, frame.pix.copy())
-
-    def init_saliency(self, width, height):
-        if not DO_SALIENCY:
-            return
-        self.saliency = cv2.saliency.MotionSaliencyBinWangApr2014_create()
-        self.saliency.setImagesize(width, height)
-        self.saliency.init()
+        if DO_SALIENCY:
+            self.saliency = cv2.saliency.MotionSaliencyBinWangApr2014_create()
+            self.saliency.setImagesize(res_x, res_y)
+            self.saliency.init()
 
     def process_frame(self, clip, frame, ffc_affected=False):
+        start = time.time()
+        if len(frame.shape) == 3:
+            # in rgb so convert to gray
+            frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
         if self.saliency is None and DO_SALIENCY:
             if self.resize_dims is not None:
                 self.init_saliency(
@@ -201,7 +305,6 @@ def process_frame(self, clip, frame, ffc_affected=False):
         if ffc_affected:
             self.print_if_verbose("{} ffc_affected".format(clip.current_frame))
         clip.ffc_affected = ffc_affected
-
         self._process_frame(clip, frame, ffc_affected)
 
     def _get_filtered_frame_ir(self, thermal, repeats=1):
@@ -216,92 +319,114 @@ def _get_filtered_frame_ir(self, thermal, repeats=1):
     # keep merging until no more merges are possible, tihs works paticularly well from the IR videos where
     # the filtered image is quite fragmented
     def merge_components(self, rectangles):
-        MAX_GAP = 20
+        min_mass = 10 * 4
+        min_size = 16
+        MAX_GAP = 40
+        if self.scale:
+            min_mass = int(min_mass * self.scale)
+            min_size = int(min_size * self.scale)
+            MAX_GAP *= self.scale
+        rectangles = [
+            r
+            for r in rectangles
+            if r[4] > min_mass or (r[2] > min_size and r[3] > min_size)
+        ]
+        rectangles = sorted(rectangles, key=lambda s: s[4], reverse=True)
+        # only merge on original rectangle not on resized
+        for i in range(len(rectangles)):
+            rectangles[i] = (rectangles[i], rectangles[i].copy())
         rect_i = 0
         rectangles = list(rectangles)
         while rect_i < len(rectangles):
-            rect = rectangles[rect_i]
+            rect, merged_r = rectangles[rect_i]
             merged = False
             mid_x = rect[2] / 2.0 + rect[0]
             mid_y = rect[3] / 2.0 + rect[1]
             index = 0
             while index < len(rectangles):
-                r_2 = rectangles[index]
+                within = False
+                r_2 = rectangles[index][0]
                 if r_2[0] == rect[0]:
                     index += 1
                     continue
+
                 r_mid_x = r_2[2] / 2.0 + r_2[0]
                 r_mid_y = r_2[3] / 2.0 + r_2[1]
                 distance = (mid_x - r_mid_x) ** 2 + (r_mid_y - mid_y) ** 2
                 distance = distance**0.5
 
-                # widest = max(rect[2], rect[3])
-                # hack short cut just take line from mid points as shortest distance subtract biggest width or hieght from each
-                distance = (
-                    distance - max(rect[2], rect[3]) / 2.0 - max(r_2[2], r_2[3]) / 2.0
-                )
-                within = r_2[0] > rect[0] and (r_2[0] + r_2[2]) <= (rect[0] + rect[2])
-                within = (
-                    within
-                    and r_2[1] > rect[1]
-                    and (r_2[1] + r_2[3]) <= (rect[1] + rect[3])
-                )
-
+                if r_2[2] + rect[2] > max(r_2[0] + r_2[2], rect[2] + rect[0]) - min(
+                    r_2[0], rect[0]
+                ):
+                    within = r_2[3] + rect[3] > max(
+                        r_2[1] + r_2[3], rect[1] + rect[3]
+                    ) - min(r_2[1], rect[1])
+                distance = rect_distance(rect, r_2)
                 if distance < MAX_GAP or within:
-                    cur_right = rect[0] + rect[2]
-                    cur_bottom = rect[0] + rect[2]
-
-                    rect[0] = min(rect[0], r_2[0])
-                    rect[1] = min(rect[1], r_2[1])
-                    rect[2] = max(cur_right, r_2[0] + r_2[2])
-                    rect[3] = max(rect[1] + rect[3], r_2[1] + r_2[3])
-                    rect[2] -= rect[0]
-                    rect[3] -= rect[1]
-                    rect[4] += r_2[4]
-
-                    # print("second merged ", rect)
+                    cur_right = merged_r[0] + merged_r[2]
+                    cur_bottom = merged_r[0] + merged_r[2]
+
+                    merged_r[0] = min(merged_r[0], r_2[0])
+                    merged_r[1] = min(merged_r[1], r_2[1])
+                    merged_r[2] = max(cur_right, r_2[0] + r_2[2])
+                    merged_r[3] = max(merged_r[1] + merged_r[3], r_2[1] + r_2[3])
+                    merged_r[2] -= merged_r[0]
+                    merged_r[3] -= merged_r[1]
+                    merged_r[4] += r_2[4]
                     merged = True
                     # break
                     del rectangles[index]
                 else:
                     index += 1
-                    # print("not mered", rect, r_2, distance)
             if merged:
                 rect_i = 0
             else:
                 rect_i += 1
+        rectangles = [rect[1] for rect in rectangles]
         return rectangles
 
-    def _process_frame(self, clip, thermal, ffc_affected=False):
+    def _process_frame(self, clip, frame, ffc_affected=False):
         wait = 1
         """
         Tracks objects through frame
         :param thermal: A numpy array of shape (height, width) and type uint16
             If specified background subtraction algorithm will be used.
         """
-        repeats = 1
-        if clip.current_frame < 6:
-            # helps init the saliency, when ir tracks have a 5 second preview this shouldnt be needed
-            repeats = 6
-
-        tracking_thermal = thermal
-        if self.scale:
-            tracking_thermal = cv2.resize(
-                thermal, (int(self.res_x * self.scale), int(self.res_y * self.scale))
-            )
 
         saliencyMap = None
+        filtered = None
         if self.do_tracking:
-            saliencyMap, _ = self._get_filtered_frame_ir(
-                tracking_thermal, repeats=repeats
-            )
-            if self.scale:
-                saliencyMap = cv2.resize(
-                    saliencyMap, (clip.res_x, clip.res_y), cv2.INTER_NEAREST
+            if self.background._background is None:
+                self.background.set_background(frame.copy())
+            # GP NOT using probably can remove
+            if DO_SALIENCY:
+                repeats = 1
+                if clip.current_frame < 6:
+                    # helps init the saliency, when ir tracks have a 5 second preview this shouldnt be needed
+                    repeats = 6
+                saliency_thermal = None
+                tracking_thermal = frame
+                if self.scale:
+                    tracking_thermal = cv2.resize(
+                        tracking_thermal,
+                        (int(self.res_x * self.scale), int(self.res_y * self.scale)),
+                        interpolation=cv2.INTER_AREA,
+                    )
+                saliencyMap, _ = self._get_filtered_frame_ir(
+                    tracking_thermal, repeats=repeats
                 )
-        backsub, _ = get_ir_back_filtered(
-            self.background.background, thermal, clip.background_thresh
-        )
+            if self.update_background:
+                has_motion = self.background.movement_detected
+                learning_rate = 0 if has_motion else self.learning_rate
+                self.background.update_background(
+                    frame, learning_rate=self.learning_rate
+                )
+                # self.background.detect_motion()
+
+            filtered = self.background.compute_filtered(frame)
+            if not clip.background_calculated:
+                clip.set_background(self.background.background)
+
         threshold = 0
         if np.amin(saliencyMap) == 255:
             num = 0
@@ -309,17 +434,21 @@ def _process_frame(self, clip, thermal, ffc_affected=False):
             component_details = []
             saliencyMap[:] = 0
         else:
-            backsub = np.where(saliencyMap > 0, saliencyMap, backsub)
+            pass
+            # backsub = np.where(saliencyMap > 0, saliencyMap, backsub)
+        cur_frame = clip.add_frame(frame, filtered, saliencyMap, ffc_affected)
 
-        cur_frame = clip.add_frame(thermal, backsub, saliencyMap, ffc_affected)
-        self.background.update_background(cur_frame)
-        clip.set_background(self.background.background)
         if not self.do_tracking:
             return
-
-        # else:
-
-        num, mask, component_details = theshold_saliency(backsub, threshold=0)
+        re_f = filtered
+        if self.scale:
+            re_f = cv2.resize(
+                filtered,
+                (int(self.res_x * self.scale), int(self.res_y * self.scale)),
+                interpolation=cv2.INTER_AREA,
+            )
+        # self.debug_tracking(frame, filtered)
+        num, mask, component_details = detect_objects_ir(re_f, threshold=0)
         component_details = component_details[1:]
         component_details = self.merge_components(component_details)
         if clip.from_metadata:
@@ -335,13 +464,83 @@ def _process_frame(self, clip, thermal, ffc_affected=False):
             if ffc_affected:
                 clip.active_tracks = set()
             else:
-                regions = self._get_regions_of_interest(
-                    clip,
-                    component_details,
-                )
+                s = time.time()
+                regions = self._get_regions_of_interest(clip, component_details)
                 self._apply_region_matchings(clip, regions)
-
+            for track in clip.active_tracks:
+                if track.trap_reported:
+                    continue
+                self.inside_trap_top(track, self.scale)
+                if track.in_trap:
+                    filtered = self.filter_track(clip, track, track.get_stats())
+                    if not filtered:
+                        track.trigger_frame = cur_frame.frame_number
+                        # fire trapping event
+                        if self.on_trapped is not None:
+                            track.trap_reported = True
+                            self.on_trapped(track)
             clip.region_history.append(regions)
+            if DEBUG_TRAP:
+                if len(clip.tracks) > 0:
+                    self.show_trap_info(clip, frame)
+
+    def debug_tracking(self, frame, filtered):
+        cv2.imshow("gray", frame)
+        cv2.imshow("f", filtered)
+        cv2.imshow("b", self.background.background)
+        cv2.moveWindow("gray", 0, 0)
+        cv2.moveWindow("f", 640, 0)
+        cv2.waitKey()
+
+    def show_trap_info(self, clip, frame):
+        image = frame.copy()
+        in_trap = False
+        wait = 100
+        start = (0, 480 - int(self.left_bottom.y_res(0)))
+        end = (int(self.left_bottom.x_res(480)), 0)
+
+        image = cv2.line(image, start, end, (255, 255, 0), 10)
+        start = (int(self.right_bottom.x_res(480)), 0)
+        end = (int(self.right_bottom.x_res(0)), 480)
+
+        image = cv2.line(image, start, end, (255, 255, 0), 10)
+
+        for track in clip.active_tracks:
+            filtered = self.filter_track(clip, track, track.get_stats())
+            in_trap = in_trap or (track.in_trap and not filtered)
+            region = track.bounds_history[-1]
+            # if self.scale:
+            #     region = region.copy()
+            #     region.rescale(1 / self.scale)
+            region.in_trap = track.last_bound.in_trap
+            start_point = (int(region.left), int(region.top))
+            end_point = (int(region.right), int(region.bottom))
+            image = cv2.rectangle(image, start_point, end_point, (255, 0, 0), 2)
+            if region.in_trap:
+                text_point = (start_point[0], start_point[1] + 50)
+                image = cv2.putText(
+                    image,
+                    f"Trapped?{ track.in_trap} f?{filtered} {track.direction}",
+                    text_point,
+                    cv2.FONT_HERSHEY_SIMPLEX,
+                    1,
+                    (255, 0, 0),
+                )
+                # wait = 300
+        if in_trap:
+            image = cv2.putText(
+                image,
+                f"CAUGHT EM",
+                (50, 50),
+                cv2.FONT_HERSHEY_SIMPLEX,
+                1,
+                (255, 0, 0),
+            )
+            wait = 300
+
+        cv2.imshow("id", image)
+        cv2.waitKey(100)
+        # 1 / 0
 
     def filter_components(self, component_details):
         filtered = []
@@ -355,16 +554,15 @@ def filter_components(self, component_details):
             p = (region.right, 480 - region.bottom)
 
             filter = (
-                IRTrackExtractor.LEFT_BOTTOM.is_above(p)
-                and IRTrackExtractor.LEFT_BOTTOM.is_left(p)
-                # or IRTrackExtractor.RIGHT_BOTTOM.is_above(region)
+                self.left_bottom.is_above(p)
+                and self.left_bottom.is_left(p)
+                # or self.right_bottom.is_above(region)
                 # or IRTrackExtractor.BACK_BOTTOM.is_above(region)
             )
             p = (region.left, 480 - region.bottom)
 
             filter = filter or (
-                IRTrackExtractor.RIGHT_BOTTOM.is_above(p)
-                and IRTrackExtractor.RIGHT_BOTTOM.is_right(p)
+                self.right_bottom.is_above(p) and self.right_bottom.is_right(p)
             )
             p = (region.left, 480 - region.bottom)
 
@@ -420,49 +618,177 @@ def get_delta_frame(self, clip):
         # GP used to be 50 frames ago
         frame = clip.frame_buffer.current_frame
         prev_i = max(0, min(10, clip.current_frame - 10))
+        s = time.time()
         prev_frame = clip.frame_buffer.get_frame_ago(prev_i)
-        if prev_i == frame.frame_number:
+        if (
+            prev_frame is None
+            or prev_i == frame.frame_number
+            or prev_frame.filtered is None
+        ):
             return None, None
-        filtered, _ = normalize(frame.filtered, new_max=255)
-        prev_filtered, _ = normalize(prev_frame.filtered, new_max=255)
-        delta_filtered = np.abs(np.float32(filtered) - np.float32(prev_filtered))
 
-        thermal, _ = normalize(frame.thermal, new_max=255)
-        prev_thermal, _ = normalize(prev_frame.thermal, new_max=255)
-        delta_thermal = np.abs(np.float32(thermal) - np.float32(prev_thermal))
-        return delta_thermal, delta_filtered
+        s = time.time()
 
+        delta_filtered = np.abs(frame.filtered - prev_frame.filtered)
+        delta_thermal = np.abs(frame.thermal - prev_frame.thermal)
+        return delta_thermal, delta_filtered
 
-def get_ir_back_filtered(background, thermal, back_thresh):
-    """
-    Calculates filtered frame from thermal
-    :param thermal: the thermal frame
-    :param background: (optional) used for background subtraction
-    :return: uint8 filtered frame and adjusted clip threshold for normalized frame
-    """
+    def inside_trap_bottom(self, track, scale=None):
+        region = track.last_bound.copy()
+        # if scale:
+        #     region.rescale(1 / scale)
+        if region.width < 60 or region.height < 40:
+            # dont want small regions
+            return False
+        if track.direction == 0:
+            if region.left < 100:
+                track.direction |= Direction.LEFT
+            if region.right > (640 - 100):
+                track.direction |= Direction.RIGHT
+            if region.bottom > (480 - 100):
+                track.direction |= Direction.BOTTOM
+            if track.direction == 0:
+                if region.bottom < 300:
+                    track.direction |= Direction.TOP
+                else:
+                    track.direction = Direction.MIDDLE
+        p = (region.left, 480 - region.bottom)
+        # bottom left region below left Line
+        # bottom right region below right line
+
+        # alternative method is to use
+        # top right is below and rigth of left line
+        # top left is below and left of right line
+        # this fixes problem when animal comes from behind trap towards camera
+        # this triggerrs early for bottom areas
+        inside = self.left_bottom.is_below(p) and self.left_bottom.is_right(p)
+        p = (region.right, 480 - region.bottom)
+        inside = inside and (
+            self.right_bottom.is_below(p) and self.right_bottom.is_left(p)
+        )
 
-    filtered = np.float32(thermal.copy())
+        x_diff = p[0] - self.right_bottom.x_res(p[1])
+        inside = inside and abs(x_diff) > 150
+        track.last_bound.in_trap = inside
+        track.update_trapped_state()
+        return inside
+
+    def inside_trap_top(self, track, scale=None):
+        SIDE_ALLOWANCE = 150
+        TOP_ALLOWANCE = 300
+        BOTTOM_ALLOWANCE = 100
+        region = track.last_bound.copy()
+        # if scale:
+        # region.rescale(1 / scale)
+        if region.width < 60 or region.height < 40:
+            # dont want small regions
+            return False
+
+        # Set direction track is coming from
+        # this should help filter out some FPS
+        if track.direction == 0:
+            if region.left < SIDE_ALLOWANCE:
+                track.direction |= Direction.LEFT
+            if region.right > (640 - SIDE_ALLOWANCE):
+                track.direction |= Direction.RIGHT
+            if region.bottom > (480 - BOTTOM_ALLOWANCE):
+                track.direction |= Direction.BOTTOM
+            if track.direction == 0:
+                if region.bottom < TOP_ALLOWANCE:
+                    track.direction |= Direction.TOP
+                else:
+                    track.direction = Direction.MIDDLE
 
-    avg_change = 0
-    filtered = abs(filtered - background)
-    filtered[filtered < back_thresh] = 0
-    filtered, stats = normalize(filtered, new_max=255)
+        # check top right is to the left and below left trap boundary line
+        p = (region.right, 480 - region.top)
+        inside = self.left_bottom.is_below(p) and self.left_bottom.is_right(p)
+        x_pos = self.left_bottom.x_res(p[1])
+        x_diff = abs(p[0] - x_pos)
+        # amount of animal inside trap from left
+        left_percent = x_diff / region.width
 
-    # filtered[filtered > 10] += 30
-    return filtered, 0
+        p = (region.left, 480 - region.top)
 
+        # check top left is to the right and below right trap boundary line
+        inside = (
+            inside and self.right_bottom.is_below(p) and self.right_bottom.is_left(p)
+        )
 
-class Background:
-    def __init__(self, frame):
-        self._background = np.float32(frame)
-        self.frames = 1
+        x_pos = self.right_bottom.x_res(p[1])
+        # could try using bottom  rather than region.top here
+        x_diff = abs(p[0] - x_pos)
+        # amount of animal inside trap from right
+        right_percent = x_diff / region.width
+
+        if not inside:
+            return False
+        in_trap = False
+
+        # atleast half the animal should be inside the trap, sometimes tails etc
+        # can be outside trap so best to trigger a bit early than not at all
+        if left_percent < 0.5 and right_percent < 0.5:
+            return False
+        if track.direction & Direction.LEFT and region.left > 40 and left_percent > 0.5:
+            in_trap = True
+
+        elif (
+            track.direction & Direction.RIGHT
+            and region.right < 580
+            and right_percent > 0.5
+        ):
+            in_trap = True
 
-    def update_background(self, frame):
-        background = self.background
-        new_thermal = np.where(frame.filtered > 0, background, frame.thermal)
-        self._background += new_thermal
-        self.frames += 1
+        if track.direction == Direction.TOP and region.bottom > 300:
+            in_trap = True
+        if track.direction == Direction.BOTTOM and region.bottom < 480 - 50:
+            in_trap = True
 
-    @property
-    def background(self):
-        return self._background / self.frames
+        if (
+            track.direction == Direction.MIDDLE
+            and region.left > 40
+            and region.right < 580
+        ):
+            in_trap = True
+        track.last_bound.in_trap = in_trap
+        track.update_trapped_state()
+        return in_trap
+
+
+class Direction:
+    LEFT = 1
+    BOTTOM = 2
+    RIGHT = 4
+    TOP = 8
+    MIDDLE = 16
+
+
+def rect_distance(r_a, r_b):
+    x_1 = 0
+    x_2 = 0
+    y_1 = 0
+    y_2 = 0
+    if r_a[2] + r_b[2] > max(r_a[0] + r_a[2], r_b[2] + r_b[0]) - min(r_a[0], r_b[0]):
+        # if over lap on  dimension then no distance
+        pass
+    elif r_a[0] < r_b[0]:
+        # right side of r_a to left side r_b
+        x_1 = r_a[0] + r_a[2]
+        x_2 = r_b[0]
+    else:
+        # right side of r_b to left side r_a
+        x_1 = r_b[0] + r_b[2]
+        x_2 = r_a[0]
+    overlap = r_a[3] + r_b[3] > max(r_a[1] + r_a[3], r_b[1] + r_b[3]) - min(
+        r_a[1], r_b[1]
+    )
+    if overlap:
+        # if over lap on  dimension then no distance
+        pass
+    elif r_a[1] < r_b[1]:
+        # r_a above r_b bottom r_a top of r_b
+        y_1 = r_a[1] + r_a[3]
+        y_2 = r_b[1]
+    else:
+        y_1 = r_b[1] + r_b[3]
+        y_2 = r_a[1]
+    return eucl_distance_sq((x_1, y_1), (x_2, y_2)) ** 0.5
diff --git a/ml_tools/forestmodel.py b/ml_tools/forestmodel.py
index ca80b1e0..a30a9e31 100644
--- a/ml_tools/forestmodel.py
+++ b/ml_tools/forestmodel.py
@@ -118,35 +118,100 @@ def normalize(features):
 class ForestModel(Interpreter):
     TYPE = "RandomForest"
 
-    def __init__(self, model_file):
-        super().__init__(model_file)
+    def __init__(self, model_file, data_type=None):
+        super().__init__(model_file, data_type)
+        model_file = Path(model_file)
         self.model = joblib.load(model_file)
         self.buffer_length = 5
         self.features_used = self.params.get("features_used")
 
-    def classify_track(self, clip, track, segment_frames=None):
+    def classify_track(self, clip, track, last_x_frames=None, segment_frames=None):
         track_prediction = TrackPrediction(track.get_id(), self.labels)
+        predictions, mass = self.predict_track(clip, track, last_x_frames=last_x_frames)
+        track_prediction.classified_clip(predictions, predictions * 100, [-1])
+        return track_prediction
+
+    def shape(self):
+        return (1, 188)
+
+    def preprocess(self):
+        return
+
+    def predict(self, x):
+        return self.model.predict_proba(x)
+
+    def predict_track(self, clip, track, **args):
+        last_x_frames = args.get(
+            "last_x_frames",
+        )
+        scale = args.get("scale")
+        x = process_track(clip, track, last_x_frames, scale=scale)
 
-        x = process_track(clip, track)
-        if self.features_used is not None:
-            f_mask = feature_mask(self.features_used)
-            x = np.take(x, f_mask)
         if x is None:
             logging.warning("Random forest could not classify track")
-            return None
+            return None, None
+        if self.features_used is not None and len(self.features_used) > 0:
+            f_mask = feature_mask(self.features_used)
+            x = np.take(x, f_mask)
+
         x = x[np.newaxis, :]
         predictions = self.model.predict_proba(x)
-        track_prediction.classified_clip(predictions, predictions * 100, [-1])
-        return track_prediction
+        # print("predictions", predictions.shape)
+        return predictions, [1]
 
-    def shape(self):
-        return (1, 52)
 
+def process_track(clip, track, last_x_frames=None, buf_len=5, scale=None):
+    background = clip.background
+    all_frames = None
+    frame_temp_median = {}
+    if last_x_frames is None:
+        bounds = track.bounds_history
+    else:
+        bounds = track.bounds_history[-last_x_frames:]
+        all_frames = clip.frame_buffer.get_last_x(len(bounds))
+
+    logging.debug(
+        "taking %s from %s with scale %s", len(bounds), len(track.bounds_history), scale
+    )
+    frames = []
+    # bounds = bounds[:50]
+    data_bounds = np.empty_like(bounds)
+    for i, r in enumerate(bounds):
+        # if scale is not None and scale != 1:
+        #     r = r.copy()
+        #     r.rescale(1 / scale)
+        #     data_bounds[i] = r
+        # else:
+        data_bounds[i] = bounds[i]
+        if all_frames is None:
+            frame = clip.frame_buffer.get_frame(r.frame_number)
+        else:
+            frame = all_frames[i]
+        thermal = frame.thermal
+        frames.append(thermal)
+        frame_temp_median[r.frame_number] = np.median(frame.thermal)
+        assert frame.frame_number == r.frame_number
+    if scale is not None and scale != 1:
+        resize = 1 / scale
+        background = clip.rescaled_background(
+            (int(background.shape[1] * resize), int(background.shape[0] * resize))
+        )
+    else:
+        backgorund = clip.background
+    x = forest_features(
+        frames, background, frame_temp_median, data_bounds, cropped=False
+    )
+    return x
 
-def process_track(
-    clip,
-    track,
+
+def forest_features(
+    track_frames,
+    background,
+    frame_temp_median,
+    regions,
     buf_len=5,
+    cropped=True,
+    normalize=False,
 ):
     background = clip.background
     frames = []
@@ -156,12 +221,23 @@ def process_track(
         frames.append(frame)
         frame_temp_median[r.frame_number] = np.median(frame.thermal)
     return forest_features(
-        frames, background, frame_temp_median, track.bounds_history, cropped=False
+        frames,
+        background,
+        frame_temp_median,
+        track.bounds_history,
+        cropped=False,
+        normalize=normalize,
     )
 
 
 def forest_features(
-    track_frames, background, frame_temp_median, regions, buf_len=5, cropped=True
+    track_frames,
+    background,
+    frame_temp_median,
+    regions,
+    buf_len=5,
+    cropped=True,
+    normalize=False,
 ):
     frame_features = []
     avg_features = None
@@ -170,6 +246,7 @@ def forest_features(
     all_features = []
     f_count = 0
     prev_count = 0
+    back_med = np.median(background)
     if len(track_frames) <= buf_len:
         return None
 
@@ -181,7 +258,7 @@ def forest_features(
         frame.float_arrays()
         feature = FrameFeatures(region)
         sub_back = region.subimage(background)
-        feature.calc_histogram(sub_back, frame.thermal)
+        feature.calc_histogram(sub_back, frame.thermal, normalize=normalize)
         t_median = frame_temp_median[frame.frame_number]
         if cropped:
             cropped_frame = frame
@@ -190,7 +267,7 @@ def forest_features(
         thermal = cropped_frame.thermal
         f_count += 1
 
-        thermal = thermal + np.median(background) - t_median
+        thermal = thermal + back_med - t_median
         filtered = thermal - sub_back
 
         feature.calculate(thermal, sub_back)
@@ -216,14 +293,16 @@ def forest_features(
             avg_features = features.copy()
         else:
             maximum_features = np.maximum(features, maximum_features)
-            for i, (new, min_f) in enumerate(zip(features, minimum_features)):
-                if min_f == 0:
-                    minimum_features[i] = new
-                elif new != 0 and new < min_f:
-                    minimum_features[i] = new
+            non_zero = features != 0
+            current_zero = minimum_features == 0
+            minimum_features[current_zero] = features[current_zero]
+            minimum_features[non_zero] = np.minimum(
+                minimum_features[non_zero], features[non_zero]
+            )
             # Aggregate
             avg_features += features
-
+    if f_count <= buf_len:
+        return None
     # Compute statistics for all tracks that have the min required duration
     valid_counter = 0
     N = f_count - np.array(
@@ -487,16 +566,17 @@ def features(self):
             ]
         )
 
-    def calc_histogram(self, sub_back, crop_t):
-        max_v = np.amax(sub_back)
-        min_v = np.amin(sub_back)
-        sub_back = (np.float32(sub_back) - min_v) / (max_v - min_v)
-        max_v = np.amax(crop_t)
-        min_v = np.amin(crop_t)
-        crop_t = (np.float32(crop_t) - min_v) / (max_v - min_v)
-
-        sub_back *= 255
-        crop_t *= 255
+    def calc_histogram(self, sub_back, crop_t, normalize=False):
+        if normalize:
+            max_v = np.amax(sub_back)
+            min_v = np.amin(sub_back)
+            sub_back = (np.float32(sub_back) - min_v) / (max_v - min_v)
+            max_v = np.amax(crop_t)
+            min_v = np.amin(crop_t)
+            crop_t = (np.float32(crop_t) - min_v) / (max_v - min_v)
+
+            sub_back *= 255
+            crop_t *= 255
 
         # sub_back = np.uint8(sub_back)
         # crop_t = np.uint8(crop_t)
diff --git a/ml_tools/frame.py b/ml_tools/frame.py
index 4d23e8cc..fca03016 100644
--- a/ml_tools/frame.py
+++ b/ml_tools/frame.py
@@ -116,7 +116,12 @@ def from_array(
         )
 
     def as_array(self, split_flow=True):
-        data = [self.thermal, self.filtered]
+        data = [self.thermal]
+        if self.filtered is not None:
+            data.append(self.filtered)
+        else:
+            # if no filtered return now as second array must be filtered
+            return np.array(data)
         if self.flow is None:
             if self.mask is not None:
                 data.append(self.mask)
diff --git a/ml_tools/imageprocessing.py b/ml_tools/imageprocessing.py
index a2b4f855..1b124fef 100644
--- a/ml_tools/imageprocessing.py
+++ b/ml_tools/imageprocessing.py
@@ -180,7 +180,7 @@ def save_image_channels(data, filename):
 index = 0
 
 
-def theshold_saliency(image, otsus=False, threshold=100, kernel=(15, 15)):
+def detect_objects_ir(image, otsus=False, threshold=100, kernel=(15, 15)):
     image = np.uint8(image)
     # image = cv2.fastNlMeansDenoising(np.uint8(image), None)
 
@@ -197,15 +197,6 @@ def theshold_saliency(image, otsus=False, threshold=100, kernel=(15, 15)):
     return components, small_mask, stats
 
 
-def detect_objects_ir(image, otsus=True, threshold=0, kernel=(15, 15)):
-    image = np.uint8(image)
-    image = cv2.Canny(image, 100, 200)
-    image = cv2.dilate(image, kernel, iterations=1)
-    image = cv2.morphologyEx(image, cv2.MORPH_CLOSE, kernel)
-    components, small_mask, stats, _ = cv2.connectedComponentsWithStats(image)
-    return components, small_mask, stats
-
-
 def detect_objects_both(
     salicencyMap, backsub, threshold=30, kernel=(15, 15), otsus=False
 ):
@@ -266,3 +257,44 @@ def clear_frame(frame):
         return False
 
     return True
+
+
+def hist_diff(region, background, thermal, normalize_images=False):
+    track_back = region.subimage(background).copy()
+    track_thermal = region.subimage(thermal).copy()
+    if normalize_images:
+        track_back, _ = normalize(track_back, new_max=255)
+        track_thermal, _ = normalize(track_thermal, new_max=255)
+        track_back = np.float32(track_back)
+        track_thermal = np.float32(track_thermal)
+    h_bins = 60
+    # s_bins = 60
+    histSize = [h_bins]
+
+    hist_base = cv2.calcHist(
+        [track_back],
+        None,
+        None,
+        histSize,
+        [0, 255],
+        accumulate=False,
+    )
+    cv2.normalize(hist_base, hist_base, alpha=0, beta=1, norm_type=cv2.NORM_MINMAX)
+    hist_track = cv2.calcHist(
+        [track_thermal],
+        None,
+        None,
+        histSize,
+        [0, 255],
+        accumulate=False,
+    )
+
+    cv2.normalize(
+        hist_track,
+        hist_track,
+        alpha=0,
+        beta=1,
+        norm_type=cv2.NORM_MINMAX,
+    )
+    compared_v = cv2.compareHist(hist_track, hist_base, 0)
+    return compared_v
diff --git a/ml_tools/interpreter.py b/ml_tools/interpreter.py
index ab4bc79c..451591f0 100644
--- a/ml_tools/interpreter.py
+++ b/ml_tools/interpreter.py
@@ -2,14 +2,15 @@
 
 import json
 import logging
-
+import numpy as np
 from ml_tools.hyperparams import HyperParams
 from pathlib import Path
 
 
 class Interpreter(ABC):
-    def __init__(self, model_file):
+    def __init__(self, model_file, data_type):
         self.load_json(model_file)
+        self.data_type = data_type
 
     def load_json(self, filename):
         """Loads model and parameters from file."""
@@ -26,3 +27,142 @@ def load_json(self, filename):
     def shape(self):
         """Prediction shape"""
         ...
+
+    @abstractmethod
+    def predict(self, frames):
+        """predict"""
+        ...
+
+    def predict_track(self, clip, track, **args):
+        frames, preprocessed, mass = self.preprocess(clip, track, args)
+        # print("preprocess is %s", preprocessed)
+        if preprocessed is None or len(preprocessed) == 0:
+            return None, None, None
+        pred = self.predict(np.array(preprocessed))
+        return frames, pred, mass
+
+    def preprocess(self, clip, track, args):
+        if self.TYPE == "RandomForest":
+            return
+        last_x_frames = args.get("last_x_frames", 1)
+        scale = args.get("scale", None)
+        if self.data_type == "IR":
+            logging.info("Preprocess IR scale %s last_x %s", scale, last_x_frames)
+            from ml_tools.preprocess import (
+                preprocess_ir,
+            )
+
+            frame_ago = 1
+            # get non blank frames
+            regions = []
+            frames = []
+            for r in reversed(track.bounds_history):
+                if not r.blank:
+                    frame = clip.frame_buffer.get_frame_ago(frame_ago)
+                    if frame is None:
+                        break
+                    frames.append(frame)
+                    regions.append(r)
+                    if len(regions) == last_x_frames:
+                        break
+                frame_ago += 1
+            if len(frames) == 0:
+                return None, None, None
+            preprocessed = []
+            masses = []
+            for region, frame in zip(regions, frames):
+                if (
+                    frame is None
+                    or region.width == 0
+                    or region.height == 0
+                    or region.blank
+                ):
+                    continue
+                params = self.params
+
+                pre_f = preprocess_ir(
+                    frame.copy(),
+                    (
+                        params.frame_size,
+                        params.frame_size,
+                    ),
+                    region=region,
+                    preprocess_fn=self.preprocess_fn,
+                )
+                if pre_f is None:
+                    continue
+                preprocessed.append(pre_f)
+                masses.append(1)
+            return [frame.frame_number for f in frames], preprocessed, masses
+        elif self.data_type == "thermal":
+            from ml_tools.preprocess import (
+                preprocess_movement,
+            )
+
+            frames_per_classify = args.get("frames_per_classify", 25)
+            logging.info(
+                "Preprocess thermal scale %s frames_per_classify %s last_x %s",
+                scale,
+                frames_per_classify,
+                last_x_frames,
+            )
+            frame_ago = 0
+            # get non blank frames
+            regions = []
+            frames = []
+            for r in reversed(track.bounds_history):
+                if not r.blank:
+                    frame = clip.frame_buffer.get_frame_ago(frame_ago)
+                    if frame is None:
+                        break
+                    frame = frame
+                    regions.append(r)
+                    frames.append(frame)
+                    assert frame.frame_number == r.frame_number
+                    if len(regions) == last_x_frames:
+                        break
+                frame_ago += 1
+            if len(frames) == 0:
+                return None, None, None
+            indices = np.random.choice(
+                len(regions),
+                min(frames_per_classify, len(regions)),
+                replace=False,
+            )
+            indices.sort()
+            frames = np.array(frames)[indices]
+            regions = np.array(regions)[indices]
+
+            refs = []
+            segment_data = []
+            mass = 0
+            params = self.params
+
+            for frame, region in zip(frames, regions):
+                if region.blank:
+                    continue
+                refs.append(np.median(frame.thermal))
+                thermal_reference = np.median(frame.thermal)
+                f = frame.crop_by_region(region)
+                mass += region.mass
+                f.resize_with_aspect(
+                    (params.frame_size, params.frame_size),
+                    clip.crop_rectangle,
+                    True,
+                )
+                segment_data.append(f)
+
+            preprocessed = preprocess_movement(
+                segment_data,
+                params.square_width,
+                params.frame_size,
+                red_type=params.red_type,
+                green_type=params.green_type,
+                blue_type=params.blue_type,
+                preprocess_fn=self.preprocess_fn,
+                reference_level=refs,
+                keep_edge=params.keep_edge,
+            )
+            if preprocessed is None:
+                return None, None, mass
+            return [f.frame_number for f in frames], [preprocessed], [mass]
diff --git a/ml_tools/irdataset.py b/ml_tools/irdataset.py
index c6b8f78f..92d49590 100644
--- a/ml_tools/irdataset.py
+++ b/ml_tools/irdataset.py
@@ -17,38 +17,37 @@
 # BATCH_SIZE = 64
 
 
-def load_dataset(
-    filenames,
-    image_size,
-    num_labels,
-    deterministic=False,
-    labeled=True,
-    augment=False,
-    preprocess_fn=None,
-):
+def load_dataset(filenames, num_labels, args):
+    deterministic = args.get("deterministic", False)
+
     ignore_order = tf.data.Options()
     ignore_order.experimental_deterministic = (
         deterministic  # disable order, increase speed
     )
-    dataset = tf.data.TFRecordDataset(
-        filenames
-    )  # automatically interleaves reads from multiple files
+    dataset = tf.data.TFRecordDataset(filenames)
     dataset = dataset.with_options(
         ignore_order
     )  # uses data as soon as it streams in, rather than in its original order
+
+    image_size = args["image_size"]
+    labeled = args.get("labeled", True)
+    augment = args.get("augment", False)
+    preprocess_fn = args.get("preprocess_fn")
+    one_hot = args.get("one_hot", True)
+    dataset = dataset.apply(tf.data.experimental.ignore_errors())
     dataset = dataset.map(
         partial(
             read_tfrecord,
-            image_size=image_size,
             num_labels=num_labels,
+            image_size=image_size,
             labeled=labeled,
             augment=augment,
             preprocess_fn=preprocess_fn,
+            one_hot=one_hot,
         ),
         num_parallel_calls=AUTOTUNE,
         deterministic=deterministic,
     )
-
     return dataset
 
 
@@ -65,29 +64,12 @@ def get_distribution(dataset):
     return dist
 
 
-def get_resampled(
-    base_dir,
-    batch_size,
-    image_size,
-    labels,
-    reshuffle=True,
-    deterministic=False,
-    labeled=True,
-    resample=True,
-    augment=False,
-    weights=None,
-    stop_on_empty_dataset=True,
-    preprocess_fn=None,
-):
+def get_dataset(base_dir, labels, **args):
     num_labels = len(labels)
     global remapped_y
-    datasets = []
-
+    remapped = {}
     keys = []
     values = []
-    remapped = {}
-    remapped_y = {}
-
     for l in labels:
         remapped[l] = [l]
         keys.append(labels.index(l))
@@ -95,7 +77,6 @@ def get_resampled(
     if "false-positive" in labels and "insect" in labels:
         remapped["false-positive"].append("insect")
         values[labels.index("insect")] = labels.index("false-positive")
-
         del remapped["insect"]
 
     if "possum" in labels and "cat" in labels:
@@ -111,40 +92,88 @@ def get_resampled(
         default_value=tf.constant(-1),
         name="remapped_y",
     )
-    weights = [1.0] * len(remapped)
-    # if "human" in remapped:
-    #     weights[labels.index("human")] = 0
-    r_l = list(remapped.keys())
-    if "human" in r_l:
-        weights[r_l.index("human")] = 0
-    if "rodent" in r_l:
-        weights[r_l.index("rodent")] = 0.04
-
-    # weights[labels.index("cat")] = 0
-
-    for k, v in remapped.items():
-        filenames = []
-        for label in v:
-            filenames.append(tf.io.gfile.glob(f"{base_dir}/{label}-0*.tfrecord"))
-        dataset = load_dataset(
-            filenames,
-            image_size,
-            num_labels,
-            deterministic=deterministic,
-            labeled=labeled,
-            augment=augment,
-            preprocess_fn=preprocess_fn,
+    filenames = tf.io.gfile.glob(f"{base_dir}/*.tfrecord")
+    dataset = load_dataset(filenames, num_labels, args)
+    if dataset is None:
+        logging.warn("No dataset for %s", filenames)
+        return None, None
+    resample_data = args.get("resample", True)
+    if resample_data:
+        logging.info("Resampling data")
+        dataset = resample(dataset, labels)
+    if dataset is None:
+        logging.warn("No dataset for %s", filenames)
+        return None, None
+    if not args.get("only_features"):
+        logging.info("shuffling data")
+        dataset = dataset.shuffle(
+            4096, reshuffle_each_iteration=args.get("reshuffle", True)
         )
-        dataset = dataset.shuffle(2048, reshuffle_each_iteration=True)
+    # tf refues to run if epoch sizes change so we must decide a costant epoch size even though with reject res
+    # it will chang eeach epoch, to ensure this take this repeat data and always take epoch_size elements
+    epoch_size = len([0 for x, y in dataset])
+    logging.info("Setting dataset size to %s", epoch_size)
+    if not args.get("only_features", False):
+        dataset = dataset.repeat(2)
+    scale_epoch = args.get("scale_epoch", None)
+    if scale_epoch:
+        epoch_size = epoch_size // scale_epoch
+    dataset = dataset.take(epoch_size)
+    dataset = dataset.prefetch(buffer_size=AUTOTUNE)
+    batch_size = args.get("batch_size", None)
+    if batch_size is not None:
+        dataset = dataset.batch(batch_size)
+    return dataset, remapped
+
+
+def resample(dataset, labels):
+    excluded_labels = ["sheep"]
+    num_labels = len(labels)
+    true_categories = [y for x, y in dataset]
+    if len(true_categories) == 0:
+        logging.warn("No data found")
+        return None
+    true_categories = np.int64(tf.argmax(true_categories, axis=1))
+    c = Counter(list(true_categories))
+    dist = np.empty((num_labels), dtype=np.float32)
+    target_dist = np.empty((num_labels), dtype=np.float32)
+    for i in range(num_labels):
+        if labels[i] in excluded_labels:
+            logging.info("Excluding %s for %s", c[i], labels[i])
+            dist[i] = 0
+        else:
+            dist[i] = c[i]
+            logging.info("Have %s for %s", dist[i], labels[i])
+    zeros = dist[dist == 0]
+    non_zero_labels = num_labels - len(zeros)
+    target_dist[:] = 1 / non_zero_labels
+
+    dist = dist / np.sum(dist)
+    dist_max = np.max(dist)
+    # really this is what we want but when the values become too small they never get sampled
+    # so need to try reduce the large gaps in distribution
+    # can use class weights to adjust more, or just throw out some samples
+    max_range = target_dist[0] / 2
+    for i in range(num_labels):
+        if dist[i] == 0:
+            target_dist[i] = 0
+        elif dist_max - dist[i] > (max_range * 2):
+            target_dist[i] = dist[i]
 
-        datasets.append(dataset)
-    resampled_ds = tf.data.Dataset.sample_from_datasets(
-        datasets, weights=weights, stop_on_empty_dataset=stop_on_empty_dataset
+        target_dist[i] = max(0, target_dist[i])
+    target_dist = target_dist / np.sum(target_dist)
+
+    rej = dataset.rejection_resample(
+        class_func=class_func,
+        target_dist=target_dist,
     )
-    resampled_ds = resampled_ds.shuffle(2048, reshuffle_each_iteration=reshuffle)
-    resampled_ds = resampled_ds.prefetch(buffer_size=AUTOTUNE)
-    resampled_ds = resampled_ds.batch(batch_size)
-    return resampled_ds, remapped
+    dataset = rej.map(lambda extra_label, features_and_label: features_and_label)
+    return dataset
+
+
+def class_func(features, label):
+    label = tf.argmax(label)
+    return label
 
 
 #
@@ -155,7 +184,7 @@ def preprocess(data):
 
 
 def read_tfrecord(
-    example, image_size, num_labels, labeled, augment, preprocess_fn=None
+    example, image_size, num_labels, labeled, augment, preprocess_fn=None, one_hot=True
 ):
     tfrecord_format = {
         "image/augmented": tf.io.FixedLenFeature((), tf.int64, 0),
@@ -193,8 +222,9 @@ def read_tfrecord(
         label = tf.cast(example["image/class/label"], tf.int32)
         global remapped_y
         label = remapped_y.lookup(label)
-        onehot_label = tf.one_hot(label, num_labels)
-        return image, onehot_label
+        if one_hot:
+            label = tf.one_hot(label, num_labels)
+        return image, label
     return image
 
 
@@ -224,15 +254,12 @@ def main():
     labels = meta.get("labels", [])
     datasets = []
     # weights = [0.5] * len(labels)
-    resampled_ds, remapped = get_resampled(
+    resampled_ds, remapped = get_dataset(
         f"{config.tracks_folder}/training-data/test",
-        1,
-        (160, 160),
         labels,
-        # distribution=meta["counts"]["test"],
-        stop_on_empty_dataset=True,
+        batch_size=1,
+        image_size=(160, 160),
         augment=False,
-        # preprocess_fn=tf.keras.applications.inception_v3.preprocess_input,
     )
     meta["remapped"] = remapped
     with open(file, "w") as f:
diff --git a/ml_tools/irwriter.py b/ml_tools/irwriter.py
index 4da04b37..c2df90e3 100644
--- a/ml_tools/irwriter.py
+++ b/ml_tools/irwriter.py
@@ -43,7 +43,7 @@
 from . import tfrecord_util
 from ml_tools import tools
 from ml_tools.imageprocessing import normalize, rotate
-from load.irtrackextractor import get_ir_back_filtered
+from track.cliptracker import get_diff_back_filtered
 import cv2
 import random
 import math
@@ -109,7 +109,7 @@ def create_tf_example(frame, image_dir, sample, labels, filename):
     # image.save(encoded_jpg_io, format="JPEG")
     # encoded_mask = encoded_jpg_io.getvalue()
     # mask_key = hashlib.sha256(encoded_mask).hexdigest()
-
+    print("writing image of size", frame.thermal.shape)
     feature_dict = {
         "image/augmented": tfrecord_util.int64_feature(sample.augment),
         "image/height": tfrecord_util.int64_feature(image_height),
@@ -204,16 +204,12 @@ def create_tf_records(
     lbl_counts = [0] * num_labels
 
     writers = []
-    for label in labels:
-        for i in range(num_shards):
-            writers.append(
-                tf.io.TFRecordWriter(
-                    str(
-                        output_path
-                        / (f"{label}-%05d-of-%05d.tfrecord" % (i, num_shards))
-                    )
-                )
+    for i in range(num_shards):
+        writers.append(
+            tf.io.TFRecordWriter(
+                str(output_path / (f"%05d-of-%05d.tfrecord" % (i, num_shards)))
             )
+        )
     load_first = 200
     try:
         count = 0
@@ -245,9 +241,8 @@ def create_tf_records(
                     l_i = labels.index(sample.label)
 
                     total_num_annotations_skipped += num_annotations_skipped
-                    writers[num_shards * l_i + lbl_counts[l_i] % num_shards].write(
-                        tf_example.SerializeToString()
-                    )
+                    writer = writers[count % num_shards]
+                    writer.write(tf_example.SerializeToString())
                     count += 1
                     lbl_counts[l_i] += 1
 
@@ -302,7 +297,7 @@ def get_data(db, sample, cropped, back_thresh):
         f.crop_by_region(region, out=f)
         background = region.subimage(background)
     f.mask = f.filtered
-    f.filtered, _ = get_ir_back_filtered(background, f.thermal, back_thresh)
+    f.filtered = get_diff_back_filtered(background, f.thermal, back_thresh)
 
     assert f.thermal.shape == f.filtered.shape
     f.normalize()
diff --git a/ml_tools/kerasmodel.py b/ml_tools/kerasmodel.py
index 91119f63..c8d5910a 100644
--- a/ml_tools/kerasmodel.py
+++ b/ml_tools/kerasmodel.py
@@ -28,7 +28,10 @@
     get_weighting,
 )
 from ml_tools.thermaldataset import get_distribution
-from ml_tools.irdataset import get_resampled as get_ir_dataset
+from ml_tools.irdataset import get_dataset as get_ir_dataset
+
+import tensorflow_decision_forests as tfdf
+from ml_tools import forestmodel
 
 import tensorflow_decision_forests as tfdf
 from ml_tools import forestmodel
@@ -38,6 +41,7 @@ class KerasModel(Interpreter):
     """Defines a deep learning model"""
 
     VERSION = 1
+    TYPE = "Keras"
 
     def __init__(self, train_config=None, labels=None, data_dir=None):
         self.model = None
@@ -339,7 +343,8 @@ def load_weights(self, model_path, meta=True, training=False):
         # self.model.summary()
         # self.model.load_weights(dir + "/variables/variables")
 
-    def load_model(self, model_path, training=False, weights=None):
+    def load_model(self, model_path, training=False, weights=None, data_type="thermal"):
+        super().__init__(model_path, data_type)
         logging.info("Loading %s with weight %s", model_path, weights)
         dir = os.path.dirname(model_path)
         self.model = tf.keras.models.load_model(dir)
@@ -507,7 +512,6 @@ def train_model_tfrecords(
             include_features=self.params.mvm,
             # dist=self.dataset_counts["validation"],
         )
-        # distribution = get_distribution(self.train)
         if rebalance:
             self.class_weights = get_weighting(self.train, self.labels)
             logging.info(
@@ -748,11 +752,16 @@ def classify_ir(self, clip, track, keep_all=True, segment_frames=None):
         data = []
         crop = True
         thermal_median = np.empty(len(track.bounds_history), dtype=np.uint16)
+        frames_used = []
         for i, region in enumerate(track.bounds_history):
             if region.blank:
                 continue
             if region.width == 0 or region.height == 0:
-                logging.warn("No width or height for frame %s", region.frame_number)
+                logging.warn(
+                    "No width or height for frame %s regoin %s",
+                    region.frame_number,
+                    region,
+                )
                 continue
             frame = clip.frame_buffer.get_frame(region.frame_number)
             if frame is None:
@@ -767,7 +776,14 @@ def classify_ir(self, clip, track, keep_all=True, segment_frames=None):
                         clip.get_id(), track.get_id(), region.frame_number
                     )
                 )
-
+            logging.info(
+                "classifying ir with preprocess %s size %s crop? %s f shape %s region %s",
+                self.preprocess_fn.__module__,
+                self.params.frame_size,
+                crop,
+                frame.thermal.shape,
+                region,
+            )
             preprocessed = preprocess_ir(
                 frame.copy(),
                 (
@@ -779,13 +795,43 @@ def classify_ir(self, clip, track, keep_all=True, segment_frames=None):
                 self.preprocess_fn,
                 save_info=f"{region.frame_number} - {region}",
             )
-
+            logging.info(
+                "preprocessed is %s max %s min %s",
+                preprocessed.shape,
+                np.amax(preprocessed),
+                np.amin(preprocessed),
+            )
+            frames_used.append(region.frame_number)
             data.append(preprocessed)
         data = np.float32(data)
+
+        # GP TEST STUFF PLEASE DELETE ME LATER
+        for f in data:
+            image = f.copy()
+            image = (image + 1) * 127.5
+            image = np.uint8(image)
+            image = cv2.resize(image, (600, 600))
+            out = self.model.predict(np.expand_dims(f, axis=0))
+            best_res = np.argmax(out[0])
+            prediction = self.labels[best_res]
+            image = cv2.putText(
+                image,
+                prediction,
+                (10, 10),
+                cv2.FONT_HERSHEY_SIMPLEX,
+                1,
+                (255, 0, 0),
+            )
+
+            cv2.imshow("f", image)
+            cv2.moveWindow("f", 0, 0)
+            cv2.waitKey()
+        # GP TEST STUFF PLEASE DELETE ME LATER
+
         output = self.model.predict(data)
         track_prediction = TrackPrediction(track.get_id(), self.labels)
 
-        track_prediction.classified_clip(output, output, None)
+        track_prediction.classified_clip(output, output, np.array(frames_used))
         track_prediction.normalize_score()
         return track_prediction
 
@@ -845,8 +891,8 @@ def classify_track_data(
         track_prediction.classify_time = time.time() - start
         return track_prediction
 
-    def predict(self, frame):
-        return self.model.predict(frame[np.newaxis, :])[0]
+    def predict(self, frames):
+        return self.model.predict(frames)
 
     def classify_frame(self, frame, thermal_median, preprocess=True):
         if preprocess:
diff --git a/ml_tools/mpeg_creator.py b/ml_tools/mpeg_creator.py
index 68e274a4..ed945171 100644
--- a/ml_tools/mpeg_creator.py
+++ b/ml_tools/mpeg_creator.py
@@ -1,6 +1,7 @@
 import locale
 import os
 import subprocess
+import logging
 
 
 class MPEGCreator:
@@ -18,23 +19,36 @@ class MPEGCreator:
     The output from ffmpeg is available via the `output` property.
     """
 
-    def __init__(self, filename, quality=21):
+    def __init__(
+        self,
+        filename,
+        quality=21,
+        fps=9,
+        codec="libx264",
+        bitrate="400K",
+        pix_fmt="bgr24",
+    ):
         self.filename = filename
         self.quality = quality
         self._ffmpeg = None
         self._output = []
+        self.fps = fps
+        self.codec = codec
+        self.bitrate = bitrate
+        self.pix_fmt = pix_fmt
 
-    def next_frame(self, frame):
+    def next_frame(self, frame, shape=None):
         if self._ffmpeg is None:
-            height, width, _ = frame.shape
+            if shape is None:
+                height, width, _ = frame.shape
+            else:
+                height, width, _ = shape
             self._ffmpeg = self._start(width, height)
-
         self._ffmpeg.stdin.write(frame.tobytes())
 
     def close(self):
         if not self._ffmpeg:
             return
-
         self._ffmpeg.stdin.close()
 
         return_code = self._ffmpeg.wait(timeout=60)
@@ -52,7 +66,8 @@ def output(self):
         return (b"".join(self._output)).decode(encoding)
 
     def _start(self, width, height):
-        command = get_ffmpeg_command(self.filename, width, height, self.quality)
+        command = self.get_ffmpeg_command(width, height)
+        logging.debug("command is %s", " ".join(command))
         proc = subprocess.Popen(
             command,
             stdin=subprocess.PIPE,
@@ -67,41 +82,36 @@ def _collect_output(self):
         if buf:
             self._output.append(buf)
 
-
-def get_ffmpeg_command(filename, width, height, quality=18):
-    if os.name == "nt":
-        FFMPEG_BIN = "ffmpeg.exe"  # on Windows
-    else:
-        FFMPEG_BIN = "ffmpeg"  # on Linux ans Mac OS
-
-    command = [
-        FFMPEG_BIN,
-        "-y",  # overwrite output file if it exists
-        "-f",
-        "rawvideo",
-        "-vcodec",
-        "rawvideo",
-        "-loglevel",
-        "warning",  # minimal output
-        "-s",
-        str(width) + "x" + str(height),  # size of one frame
-        "-pix_fmt",
-        "rgb24",
-        "-r",
-        "9",  # frames per second
-        "-i",
-        "-",  # The imput comes from a pipe
-        "-an",  # Tells FFMPEG not to expect any audio
-        "-vcodec",
-        "libx264",
-        "-tune",
-        "grain",  # good for keepinn the grain in our videos
-        "-crf",
-        str(quality),  # quality, lower is better
-        "-pix_fmt",
-        "yuv420p",  # window thumbnails require yuv420p for some reason
-        "-preset",
-        "veryfast",
-        filename,
-    ]
-    return command
+    def get_ffmpeg_command(self, width, height, quality=18):
+        if os.name == "nt":
+            FFMPEG_BIN = "ffmpeg.exe"  # on Windows
+        else:
+            FFMPEG_BIN = "ffmpeg"  # on Linux ans Mac OS
+
+        command = [
+            FFMPEG_BIN,
+            "-y",  # overwrite output file if it exists
+            "-f",
+            "rawvideo",
+            "-vcodec",
+            "rawvideo",
+            "-loglevel",
+            "warning",  # minimal output
+            "-s",
+            str(width) + "x" + str(height),  # size of one frame
+            "-pix_fmt",
+            self.pix_fmt,
+            "-r",
+            str(self.fps),  # frames per second
+            "-i",
+            "-",  # The imput comes from a pipe
+            "-an",  # Tells FFMPEG not to expect any audio
+            "-c:v",
+            self.codec,
+            "-b:v",
+            "1000k",
+            "-preset",
+            "veryfast",
+            str(self.filename),
+        ]
+        return command
diff --git a/ml_tools/preprocess.py b/ml_tools/preprocess.py
index 2d4bacad..842e7405 100644
--- a/ml_tools/preprocess.py
+++ b/ml_tools/preprocess.py
@@ -4,10 +4,9 @@
 from ml_tools.frame import TrackChannels
 import logging
 from ml_tools import imageprocessing
-import enum
-import tensorflow as tf
 from track.region import Region
 import cv2
+from ml_tools.tools import FrameTypes
 
 # size to scale each frame to when loaded.
 
@@ -18,7 +17,16 @@
 res_y = 160
 
 
+# this is from tf source code same as preprocess_input
+def preprocess_fn(x):
+    x /= 127.5
+    x -= 1.0
+    return x
+
+
 def convert(image):
+    import tensorflow as tf
+
     image = tf.image.convert_image_dtype(image, tf.float32)
     return image
 
@@ -32,6 +40,8 @@ def augement_frame(frame, frame_size, dim):
     )
 
     image = convert(frame)
+    import tensorflow as tf
+
     image = tf.image.random_crop(image, size=[dim[0], dim[1], 3])
     if random.random() > 0.50:
         image = tf.image.flip_left_right(image)
@@ -43,20 +53,6 @@ def augement_frame(frame, frame_size, dim):
     return image.numpy()
 
 
-class FrameTypes(enum.Enum):
-    """Types of frames"""
-
-    thermal_tiled = 0
-    filtered_tiled = 1
-    flow_tiled = 2
-    overlay = 3
-    flow_rgb = 4
-
-    @staticmethod
-    def is_valid(name):
-        return name in FrameTypes.__members__.keys()
-
-
 def preprocess_segment(
     frames,
     frame_size,
@@ -209,7 +205,6 @@ def preprocess_ir(
         crop_rectangle = tools.Rectangle(
             0, 0, frame.thermal.shape[1], frame.thermal.shape[0]
         )
-        # region.enlarge(20, max=crop_rectangle)
         frame.crop_by_region(region, out=frame)
     frame.normalize()
     image = np.stack((frame.thermal, frame.thermal, frame.thermal), axis=2)
@@ -221,7 +216,7 @@ def preprocess_ir(
         None,
         True,
     )
-    # tools.saveclassify_rgb(np.uint8(image), f"samples/{count}")
+
     if preprocess_fn:
         image = preprocess_fn(image)
     return image
@@ -303,6 +298,7 @@ def preprocess_movement(
     #     data * 255,
     #     f"samples/{index}",
     # )
+
     if preprocess_fn:
         data = data * 255
         data = preprocess_fn(data)
diff --git a/ml_tools/previewer.py b/ml_tools/previewer.py
index af15e9dc..b3d05e63 100644
--- a/ml_tools/previewer.py
+++ b/ml_tools/previewer.py
@@ -108,7 +108,7 @@ def export_clip_preview(self, filename, clip: Clip, predictions=None):
             res_x *= 2
             res_y *= 2
 
-        mpeg = MPEGCreator(filename)
+        mpeg = MPEGCreator(str(filename))
         frame_scale = 4
         for frame_number, frame in enumerate(clip.frame_buffer):
             if self.preview_type == self.PREVIEW_RAW:
@@ -226,7 +226,7 @@ def convert_and_resize(
         if type == "IR":
             thermal = thermal[..., np.newaxis]
             thermal = np.repeat(thermal, 3, axis=2)
-            image = Image.fromarray(thermal)
+            image = Image.fromarray(np.uint8(thermal))
         else:
             image = tools.convert_heat_to_img(frame, self.colourmap, h_min, h_max)
 
@@ -322,6 +322,7 @@ def create_four_tracking_image(self, frame, min_temp, max_temp, type, frame_scal
             filtered = np.uint8(filtered)
             thermal = thermal[..., np.newaxis]
             thermal = np.repeat(thermal, 3, axis=2)
+            filtered[filtered > 0] = 255
             filtered = filtered[..., np.newaxis]
             filtered = np.repeat(filtered, 3, axis=2)
             thermal = Image.fromarray(thermal)
@@ -339,7 +340,8 @@ def create_four_tracking_image(self, frame, min_temp, max_temp, type, frame_scal
             mask = np.zeros((np.array(thermal).shape), dtype=np.uint8)
             mask = Image.fromarray(mask)
         else:
-            mask, _ = normalize(frame.mask, new_max=255)
+            mask = frame.mask * 255
+            # mask, _ = normalize(frame.mask, new_max=255)
             mask = np.uint8(mask)
 
             mask = mask[..., np.newaxis]
diff --git a/ml_tools/thermalwriter.py b/ml_tools/thermalwriter.py
index 464d7e66..6203ebde 100644
--- a/ml_tools/thermalwriter.py
+++ b/ml_tools/thermalwriter.py
@@ -231,7 +231,9 @@ def get_track_data(clip_id, track_id, db):
     clip_meta = db.get_clip_meta(clip_id)
     frame_temp_median = clip_meta["frame_temp_median"]
     regions = [f.region for f in track_frames]
-    features = forest_features(track_frames, background, frame_temp_median, regions)
+    features = forest_features(
+        track_frames, background, frame_temp_median, regions, normalize=True
+    )
     return background, track_frames, features, frame_temp_median
 
 
diff --git a/ml_tools/tools.py b/ml_tools/tools.py
index 97355559..3ef99912 100644
--- a/ml_tools/tools.py
+++ b/ml_tools/tools.py
@@ -16,6 +16,7 @@
 import datetime
 import glob
 import cv2
+import enum
 import timezonefinder
 from matplotlib.colors import LinearSegmentedColormap
 import subprocess
@@ -29,6 +30,20 @@
 GLOBAL_RESOURCES = "/usr/lib/classifier-pipeline/resources"
 
 
+class FrameTypes(enum.Enum):
+    """Types of frames"""
+
+    thermal_tiled = 0
+    filtered_tiled = 1
+    flow_tiled = 2
+    overlay = 3
+    flow_rgb = 4
+
+    @staticmethod
+    def is_valid(name):
+        return name in FrameTypes.__members__.keys()
+
+
 @attr.s(eq=False)
 class Rectangle:
     """Defines a rectangle by the topleft point and width / height."""
@@ -324,11 +339,12 @@ def add_heat_number(img, frame, scale):
 gzip_compression = {"compression": "gzip"}
 
 
-def eucl_distance(first, second):
+def eucl_distance_sq(first, second):
     first_sq = first[0] - second[0]
     first_sq = first_sq * first_sq
     second_sq = first[1] - second[1]
     second_sq = second_sq * second_sq
+
     return first_sq + second_sq
 
 
diff --git a/ml_tools/trackdatabase.py b/ml_tools/trackdatabase.py
index 267cb470..b6d0c6a6 100644
--- a/ml_tools/trackdatabase.py
+++ b/ml_tools/trackdatabase.py
@@ -144,14 +144,13 @@ def create_clip(self, clip, overwrite=True):
         :param tracker: if provided stats from tracker are used for the clip stats
         :param overwrite: Overwrites existing clip (if it exists).
         """
-        logging.info("creating clip {}".format(clip.get_id()))
+        logging.info("creating clip %s", clip.get_id())
         clip_id = str(clip.get_id())
         with HDF5Manager(self.database, "a") as f:
             clips = f["clips"]
             if overwrite and clip_id in clips:
                 del clips[clip_id]
             group = clips.create_group(clip_id)
-
             if clip is not None:
                 if clip.background is not None:
                     height, width = clip.background.shape
@@ -768,7 +767,6 @@ def add_track(
                     frame_node = cropped_frame.create_dataset(
                         str(frame_i), dims, chunks=chunks, **opts, dtype=np.int16
                     )
-
                     frame_node[:, :, :] = cropped_array
                 else:
                     skipped_frames.append(frame_i + track.start_frame)
diff --git a/openvinoclassify.py b/openvinoclassify.py
new file mode 100644
index 00000000..81edd006
--- /dev/null
+++ b/openvinoclassify.py
@@ -0,0 +1,93 @@
+import argparse
+import os
+import cv2
+from pathlib import Path
+import numpy as np
+import json
+import time
+
+
+def parse_args():
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "-m",
+        "--model",
+        help="open vino model to use",
+    )
+    parser.add_argument(
+        "-d",
+        "--device",
+        default="CPU",
+        help="Open vino device to use (CPU/MYRIAD)",
+    )
+    parser.add_argument(
+        "source",
+        help="an image to classify",
+    )
+
+    args = parser.parse_args()
+    return args
+
+
+def main():
+    args = parse_args()
+    print(args)
+    image = cv2.imread(args.source)
+    new_image = np.zeros((160, 160, 3))
+    print("image is", image.shape)
+    new_image[:, :, 0] = image[:, :, 2]
+    new_image[:, :, 1] = image[:, :, 2]
+    new_image[:, :, 2] = image[:, :, 0]
+    new_image = image
+    new_image = new_image / 128 - 1
+    print(new_image.dtype, np.amin(new_image), np.amax(new_image))
+    # preprocessing
+    interpreter = NeuralInterpreter(args.model, args.device)
+    results = interpreter.predict(new_image)
+    max_i = np.argmax(results)
+    print("Predicted results are ", np.round(100 * results), interpreter.labels[max_i])
+
+
+class NeuralInterpreter:
+    def __init__(self, model_name, device="CPU"):
+        from openvino.inference_engine import IENetwork, IECore
+
+        # super().__init__(model_name)
+        model_name = Path(model_name)
+        # can use to test on PC
+        # device = "CPU"
+        # device = "MYRIAD"
+        model_meta = model_name.with_suffix(".txt")
+        stats = json.load(open(model_meta, "r"))
+        self.labels = stats.get("labels")
+        model_xml = model_name.with_suffix(".xml")
+        model_bin = model_name.with_suffix(".bin")
+        ie = IECore()
+        ie.set_config({}, device)
+        net = ie.read_network(model=model_xml, weights=model_bin)
+        self.input_blob = next(iter(net.input_info))
+        self.out_blob = next(iter(net.outputs))
+        net.batch_size = 1
+        self.input_shape = net.input_info[self.input_blob].input_data.shape
+
+        self.exec_net = ie.load_network(network=net, device_name=device)
+
+    def predict(self, input_x):
+        if input_x is None:
+            return None
+        input_x = np.float16(input_x)
+        # rearranged_arr = np.transpose(input_x, axes=[2, 0, 1])
+        input_x = np.array([[input_x]])
+        s = time.time()
+        res = self.exec_net.infer(inputs={self.input_blob: input_x})
+
+        res = res[self.out_blob]
+        print("time take is", time.time() - s)
+        return res[0]
+
+    def shape(self):
+        return self.input_shape
+
+
+if __name__ == "__main__":
+    main()
diff --git a/pi-classifier.yaml b/pi-classifier.yaml
new file mode 100644
index 00000000..24925869
--- /dev/null
+++ b/pi-classifier.yaml
@@ -0,0 +1,11 @@
+tracking:
+  thermal:
+    denoise: false
+classify:
+  models:
+  - id: 1
+    model_file: /home/pi/tflite/converted_model.tflite
+    name: pi-tflite
+    thumbnail_model: true
+    wallaby: false
+    type: TFLite
diff --git a/piclassifier/beacon.py b/piclassifier/beacon.py
index a91ea344..a1a7e7fb 100644
--- a/piclassifier/beacon.py
+++ b/piclassifier/beacon.py
@@ -7,6 +7,25 @@
 NAME = "org.cacophony.beacon"
 PATH = "/org/cacophony/beacon"
 
+# becaons have been hard coded for old labels, so remapping new label order to send as was previously
+
+label_remap = [0, 11, 1, 2, 3, 4, 12, 5, 6, 13, 7, 8, 9, 10]
+
+#
+# def label_mapping():
+#     remap = np.arange(14)
+#     new_index = 0
+#     tack_on = ["bird/kiwi", "insect", "penguin"]
+#     tack_on = 11
+#     for i, l in enumerate(labels):
+#         if l in tack_on:
+#             remap[i] = tack_on
+#             tack_on += 1
+#         else:
+#             remap[i] = new_index
+#             new_index += 1
+
+
 #
 # def init_logging(timestamps=False):
 #     """Set up logging for use by various classifier pipeline scripts.
@@ -53,7 +72,8 @@ def classification(predictions):
             * track_prediction.class_best_score
             / np.sum(track_prediction.class_best_score)
         )
-        for i, confidence in enumerate(class_best_score):
+        for cur_i, confidence in enumerate(class_best_score):
+            i = label_remap[cur_i]
             if dbus.Byte(i) in byte_predictions:
                 byte_predictions[dbus.Byte(i)] = max(
                     dbus.Byte(int(confidence)), byte_predictions[dbus.Byte(i)]
diff --git a/piclassifier/cptvmotiondetector.py b/piclassifier/cptvmotiondetector.py
new file mode 100644
index 00000000..44cee92d
--- /dev/null
+++ b/piclassifier/cptvmotiondetector.py
@@ -0,0 +1,173 @@
+import numpy as np
+from datetime import timedelta
+import time
+import logging
+
+from .motiondetector import SlidingWindow, MotionDetector, WeightedBackground
+from ml_tools.tools import Rectangle
+
+
+class CPTVMotionDetector(MotionDetector):
+    FFC_PERIOD = timedelta(seconds=9.9)
+    BACKGROUND_WEIGHT_ADD = 0.1
+
+    def __init__(self, thermal_config, dynamic_thresh, headers, detect_after=None):
+        super().__init__(thermal_config, headers)
+        self.headers = headers
+        if headers.model and headers.model.lower() == "lepton3.5":
+            CPTVMotionDetector.BACKGROUND_WEIGHT_ADD = 1
+        self.config = thermal_config.motion
+        self.location_config = thermal_config.location
+        self.num_preview_frames = thermal_config.recorder.preview_secs * headers.fps
+        self.compare_gap = self.config.frame_compare_gap + 1
+        edge = self.config.edge_pixels
+        self.min_frames = thermal_config.recorder.min_secs * headers.fps
+        self.max_frames = thermal_config.recorder.max_secs * headers.fps
+
+        if not self.config.one_diff_only:
+            self.diff_window = SlidingWindow(
+                self.compare_gap,
+                np.int32,
+            )
+
+        self.thermal_window = SlidingWindow(self.num_preview_frames + 1, "O")
+        self.processed = 0
+        self.thermal_thresh = 0
+        self.crop_rectangle = Rectangle(
+            edge, edge, headers.res_x - 2 * edge, headers.res_y - 2 * edge
+        )
+        self._background = WeightedBackground(
+            edge,
+            self.crop_rectangle,
+            self.res_x,
+            self.res_y,
+            CPTVMotionDetector.BACKGROUND_WEIGHT_ADD,
+            self.config.temp_thresh,
+        )
+        self.movement_detected = False
+        self.dynamic_thresh = dynamic_thresh
+
+        self.triggered = 0
+
+        self.ffc_affected = False
+        if detect_after is None:
+            self.detect_after = self.thermal_window.size * 2
+        else:
+            self.detect_after = detect_after
+
+    @property
+    def calibrating(self):
+        return self.ffc_affected
+
+    def preview_frames(self):
+        return self.thermal_window.get_frames()[:-1]
+
+    @property
+    def temp_thresh(self):
+        return self._background.average
+
+    def detect(self, clipped_frame, received_at=None):
+        oldest = self.crop_rectangle.subimage(self.thermal_window.oldest_nonffc.pix)
+        oldest = np.clip(oldest, a_min=self.temp_thresh, a_max=None)
+        clipped_frame = np.clip(clipped_frame, a_min=self.temp_thresh, a_max=None)
+        delta_frame = clipped_frame - oldest
+        if not self.config.warmer_only:
+            delta_frame = abs(delta_frame)
+        if self.config.one_diff_only:
+            diff = len(delta_frame[delta_frame > self.config.delta_thresh])
+        else:
+            if self.processed > 2:
+                delta_frame2 = self.diff_window.oldest_nonffc
+                delta_frame[
+                    delta_frame >= self.config.delta_thresh
+                ] = self.config.delta_thresh
+                delta_combined = delta_frame2 + delta_frame
+                diff = len(
+                    delta_combined[delta_combined == self.config.delta_thresh * 2]
+                )
+            else:
+                delta_frame[
+                    delta_frame >= self.config.delta_thresh
+                ] = self.config.delta_thresh
+                diff = 0
+
+            self.diff_window.add(delta_frame, self.ffc_affected)
+        if diff > self.config.count_thresh:
+            if not self.movement_detected:
+                logging.debug(
+                    "{} MotionDetector motion detected {} thresh {} count {} ".format(
+                        timedelta(seconds=self.num_frames / 9),
+                        self.processed,
+                        self.temp_thresh,
+                        diff,
+                    )
+                )
+            return True
+
+        if self.movement_detected:
+            logging.debug(
+                "{} MotionDetector motion stopped thresh {} count {}".format(
+                    timedelta(seconds=self.num_frames / 9),
+                    self.temp_thresh,
+                    diff,
+                )
+            )
+        return False
+
+    @property
+    def background(self):
+        return self._background.background
+
+    def get_recent_frame(self):
+        return self.thermal_window.current
+
+    def disconnected(self):
+        self.thermal_window.reset()
+        if not self.config.one_diff_only:
+            self.diff_window.reset()
+        self.processed = 0
+
+    def process_frame(self, cptv_frame, force_process=False):
+        prev_ffc = self.ffc_affected
+        self.ffc_affected = is_affected_by_ffc(cptv_frame)
+        if self.can_record() or force_process:
+            self.thermal_window.add(cptv_frame, self.ffc_affected)
+
+            cropped_frame = np.int32(self.crop_rectangle.subimage(cptv_frame.pix))
+            if not self.ffc_affected:
+                self._background.process_frame(cropped_frame)
+            if self.ffc_affected or prev_ffc:
+                logging.debug("{} MotionDetector FFC".format(self.num_frames))
+                self.movement_detected = False
+                self.triggered = 0
+                if prev_ffc:
+                    self.thermal_window.non_ffc_index = self.thermal_window.last_index
+            elif self.processed > self.detect_after:
+                movement = self.detect(cropped_frame)
+                if movement:
+                    self.triggered += 1
+                else:
+                    self.triggered = 0
+                if self.triggered >= self.config.trigger_frames:
+                    self.movement_detected = True
+                else:
+                    self.movement_detected = False
+            self.processed += 1
+        else:
+            self.thermal_window.update_current_frame(cptv_frame, self.ffc_affected)
+            self.movement_detected = False
+
+        self.num_frames += 1
+        return self.movement_detected
+
+    def skip_frame(self):
+        return
+
+
+def is_affected_by_ffc(cptv_frame):
+    if cptv_frame.time_on is None or cptv_frame.last_ffc_time is None:
+        return False
+
+    return (
+        cptv_frame.time_on - cptv_frame.last_ffc_time
+    ) < CPTVMotionDetector.FFC_PERIOD
diff --git a/piclassifier/cptvrecorder.py b/piclassifier/cptvrecorder.py
index 8b5952e1..6ff99a6a 100644
--- a/piclassifier/cptvrecorder.py
+++ b/piclassifier/cptvrecorder.py
@@ -7,144 +7,116 @@
 from cptv import Frame
 from datetime import timedelta
 import time
-import psutil
 from piclassifier.recorder import Recorder
+from pathlib import Path
+from ml_tools.logs import init_logging
+import multiprocessing
+from .eventreporter import log_event
 
-CPTV_TEMP_EXT = ".cptv.temp"
+CPTV_EXT = ".cptv"
 
 
 class CPTVRecorder(Recorder):
-    def __init__(self, thermal_config, headers, on_recording_stopping=None):
-        self.location_config = thermal_config.location
-        self.device_config = thermal_config.device
-        self.output_dir = thermal_config.recorder.output_dir
-        self.motion = thermal_config.motion
-        self.preview_secs = thermal_config.recorder.preview_secs
-        self.writer = None
-        self.filename = None
-        self.recording = False
-        self.frames = 0
-        self.headers = headers
-        self.min_frames = thermal_config.recorder.min_secs * headers.fps
-        self.max_frames = thermal_config.recorder.max_secs * headers.fps
-        self.write_until = 0
-        self.rec_time = 0
-        self.on_recording_stopping = on_recording_stopping
-
-    def force_stop(self):
-        if not self.recording:
-            return
-
-        if self.has_minimum():
-            self.stop_recording(time.time())
-        else:
-            logging.info("Recording stopped early deleting short recording")
-            self.delete_recording()
-
-    def process_frame(self, movement_detected, cptv_frame):
-        if self.recording:
-            self.write_frame(cptv_frame)
-            if movement_detected:
-                self.write_until = self.frames + self.min_frames
-            elif self.has_minimum():
-                self.stop_recording(cptv_frame.received_at)
-                return
-
-            if self.frames == self.max_frames:
-                self.stop_recording(cptv_frame.received_at)
-
-    def has_minimum(self):
-        return self.frames > self.write_until
-
-    def start_recording(
-        self, background_frame, preview_frames, temp_thresh, frame_time
+    def __init__(
+        self,
+        thermal_config,
+        headers,
+        on_recording_stopping=None,
+        name="CPTVRecorder",
+        constant_recorder=False,
     ):
-        start = time.time()
-        if self.recording:
-            logging.warn("Already recording, stop recording first")
-            return False
-        self.frames = 0
-        self.filename = new_temp_name(frame_time)
-        self.filename = os.path.join(self.output_dir, self.filename)
-        f = open(self.filename, "wb")
-        self.writer = CPTVWriter(f)
-        self.writer.timestamp = datetime.now()
-        self.writer.latitude = self.location_config.latitude
-        self.writer.longitude = self.location_config.longitude
-        self.writer.preview_secs = self.preview_secs
-        default_thresh = self.motion.temp_thresh
-        self.motion.temp_thresh = temp_thresh
-        self.writer.motion_config = yaml.dump(self.motion.as_dict()).encode()[:255]
-        self.motion.temp_thresh = default_thresh
-        if self.headers.model:
-            self.writer.model = self.headers.model.encode()
-        if self.headers.brand:
-            self.writer.brand = self.headers.brand.encode()
-        if self.headers.firmware:
-            self.writer.firmware = self.headers.firmware.encode()
-        self.writer.camera_serial = self.headers.serial
-        f = Frame(background_frame, timedelta(), timedelta(), 0, 0)
-
-        f.background_frame = True
-        self.writer.background_frame = f
-        # add brand model fps etc to cptv when python-cptv supports
-
-        if self.device_config.name:
-            self.writer.device_name = self.device_config.name.encode()
-        if self.device_config.device_id:
-            self.writer.device_id = self.device_config.device_id
-
-        self.writer.write_header()
-
-        self.recording = True
-        for frame in preview_frames:
-            self.write_frame(frame)
-        self.write_until = self.frames + self.min_frames
-
-        logging.info("recording %s started temp_thresh: %d", self.filename, temp_thresh)
-
-        self.rec_time += time.time() - start
-        return True
-
-    def write_frame(self, cptv_frame):
-        start = time.time()
-        self.writer.write_frame(cptv_frame)
-        self.frames += 1
-        self.rec_time += time.time() - start
-
-    def stop_recording(self, frame_time):
-        start = time.time()
-        self.rec_time += time.time() - start
-        self.recording = False
-        logging.info(
-            "recording ended %s frames %s time recording %s per frame ",
-            self.frames,
-            self.rec_time,
-            self.rec_time / self.frames,
+        super().__init__(
+            thermal_config,
+            headers,
+            name,
+            constant_recorder,
+            CPTV_EXT,
+            on_recording_stopping,
         )
-        self.rec_time = 0
-
-        self.write_until = 0
-        if self.writer is None:
-            return
-        final_name = os.path.splitext(self.filename)[0]
-        if self.on_recording_stopping is not None:
-            self.on_recording_stopping(final_name)
-        self.writer.close()
-        os.rename(self.filename, final_name)
-        self.writer = None
-
-    def delete_recording(self):
-        self.recording = False
-        if self.writer is None:
-            return
-
-        self.writer.close()
-        os.remove(self.filename)
-        self.writer = None
 
+    def new_recording(self, background_frame, preview_frames, temp_thresh, frame_time):
+        self.rec_p = multiprocessing.Process(
+            target=record,
+            args=(
+                self.frame_q,
+                self.filename,
+                temp_thresh,
+                self.preview_secs,
+                self.motion,
+                self.headers,
+                self.location_config,
+                self.device_config,
+                background_frame,
+                preview_frames,
+            ),
+        )
+        self.rec_p.start()
+        return True
 
-def new_temp_name(frame_time):
-    return datetime.fromtimestamp(frame_time).strftime(
-        "%Y%m%d.%H%M%S.%f" + CPTV_TEMP_EXT
-    )
+    def final_name(self):
+        return self.output_dir / self.filename.with_suffix(self.file_extention).name
+
+
+def record(
+    queue,
+    filename,
+    temp_thresh,
+    preview_secs,
+    motion_config,
+    headers,
+    location_config,
+    device_config,
+    background_frame=None,
+    init_frames=None,
+    name="CPTVRecorder",
+):
+    init_logging()
+    frames = 0
+    try:
+        logging.info("%s Recorder %s started", name, filename.resolve())
+        f = open(filename, "wb")
+        writer = CPTVWriter(f)
+        writer.timestamp = datetime.now()
+        writer.latitude = location_config.latitude
+        writer.longitude = location_config.longitude
+        writer.preview_secs = preview_secs
+        motion_config.temp_thresh = temp_thresh
+        writer.motion_config = yaml.dump(motion_config.as_dict()).encode()[:255]
+        if headers.model:
+            writer.model = headers.model.encode()
+        if headers.brand:
+            writer.brand = headers.brand.encode()
+        if headers.firmware:
+            writer.firmware = headers.firmware.encode()
+        writer.camera_serial = headers.serial
+        if background_frame is not None:
+            f = Frame(background_frame, timedelta(), timedelta(), 0, 0)
+            f.background_frame = True
+            writer.background_frame = f
+        # add brand model fps etc to cptv when python-cptv supports
+        if device_config.name:
+            writer.device_name = device_config.name.encode()
+        if device_config.device_id:
+            writer.device_id = device_config.device_id
+
+        writer.write_header()
+        if init_frames is not None:
+            for frame in init_frames:
+                writer.write_frame(frame)
+                frames += 1
+        while True:
+            frame = queue.get()
+            if isinstance(frame, int) and frame == 0:
+                writer.close()
+                break
+            writer.write_frame(frame)
+            frames += 1
+
+    except Exception as ex:
+        logging.error("%s Error Recording %s", name, filename.resolve(), exc_info=True)
+        log_event("error-recording", ex)
+        try:
+            writer.close()
+        except:
+            pass
+    logging.info("%s Recorder %s Written %s", name, filename.resolve(), frames)
diff --git a/piclassifier/dummyrecorder.py b/piclassifier/dummyrecorder.py
index f3a383fc..aab71cb7 100644
--- a/piclassifier/dummyrecorder.py
+++ b/piclassifier/dummyrecorder.py
@@ -9,17 +9,17 @@ def __init__(self, thermal_config, headers, on_recording_stopping=None):
         self.min_frames = thermal_config.recorder.min_secs * headers.fps
         self.max_frames = thermal_config.recorder.max_secs * headers.fps
 
-    def process_frame(self, movement_detected, cptv_frame):
+    def process_frame(self, movement_detected, cptv_frame, received_at):
         if self.recording:
             self.write_frame(cptv_frame)
             if movement_detected:
                 self.write_until = self.frames + self.min_frames
             elif self.has_minimum():
-                self.stop_recording(cptv_frame.received_at)
+                self.stop_recording(received_at)
                 return
 
             if self.frames == self.max_frames:
-                self.stop_recording(cptv_frame.received_at)
+                self.stop_recording(received_at)
         return
 
     def has_minimum(self):
@@ -38,7 +38,7 @@ def start_recording(
         for f in preview_frames:
             self.write_frame(f)
         self.write_until = self.frames + self.min_frames
-        logging.info("Dummy recording started temp_thresh: %d", temp_thresh)
+        logging.info("Dummy recording started temp_thresh: %s", temp_thresh)
 
         return True
 
diff --git a/piclassifier/eventreporter.py b/piclassifier/eventreporter.py
index bba78b88..cf3dc260 100644
--- a/piclassifier/eventreporter.py
+++ b/piclassifier/eventreporter.py
@@ -1,15 +1,36 @@
 import time
 import dbus
 import logging
+import json
+from dbus.mainloop.glib import DBusGMainLoop
 
 DBUS_NAME = "org.cacophony.Events"
 DBUS_PATH = "/org/cacophony/Events"
 
 
 def throttled_event():
-    bus = dbus.SystemBus()
+    bus = dbus.SystemBus(mainloop=DBusGMainLoop())
     try:
         proxy = bus.get_object(DBUS_NAME, DBUS_PATH)
         proxy.Add("{}", "throttle", dbus.Int64(time.time_ns()))
     except:
         logging.error("throttle dbus error ", exc_info=True)
+
+
+def log_event(event_type, details=None):
+    print("TODO Fix event logging")
+    return
+    bus = dbus.SystemBus(mainloop=DBusGMainLoop())
+    data = None
+    if details is not None:
+        data = {"description": {"details": details}}
+
+    try:
+        proxy = bus.get_object(DBUS_NAME, DBUS_PATH)
+        if data is None:
+            json_d = "{}"
+        else:
+            json_d = json.dumps(data)
+        proxy.Add(json_d, event_type, dbus.Int64(time.time_ns()))
+    except:
+        logging.error("log event dbus error ", exc_info=True)
diff --git a/piclassifier/headerinfo.py b/piclassifier/headerinfo.py
index ac363a37..56e5a391 100644
--- a/piclassifier/headerinfo.py
+++ b/piclassifier/headerinfo.py
@@ -44,6 +44,7 @@ def parse_header(cls, raw_string):
             pixel_bits=raw.get(HeaderInfo.PIXEL_BITS),
             firmware=raw.get(HeaderInfo.FIRMWARE),
         )
+        headers.firmware = str(headers.firmware)
         if headers.res_x and headers.res_y:
             if not headers.pixel_bits and headers.frame_size:
                 headers.pixel_bits = int(
diff --git a/piclassifier/irmotiondetector.py b/piclassifier/irmotiondetector.py
new file mode 100644
index 00000000..561912e8
--- /dev/null
+++ b/piclassifier/irmotiondetector.py
@@ -0,0 +1,161 @@
+import numpy as np
+import cv2
+from .motiondetector import SlidingWindow, MotionDetector
+from cptv import Frame
+import logging
+from track.cliptracker import (
+    ClipTracker,
+    CVBackground,
+    DiffBackground,
+    Background,
+    get_diff_back_filtered,
+)
+
+
+class RollingBackground(Background):
+    AVERAGE_OVER = 1000
+
+    def __init__(self, background_thresh=15):
+        self._background = None
+        self.frames = 0
+        self.background_thresh = background_thresh
+
+    def update_background(self, frame):
+        if self._background is None:
+            self._background = np.float32(frame.copy())
+            return
+        if self.frames < Background.AVERAGE_OVER:
+            self._background = (self._background * self.frames + frame) / (
+                self.frames + 1
+            )
+        else:
+            self._background = (
+                self._background * (Background.AVERAGE_OVER - 1) + frame
+            ) / (Background.AVERAGE_OVER)
+
+        self.frames += 1
+
+    @property
+    def background(self):
+        return np.uint8(self._background)
+
+    @property
+    def frames(self):
+        return min(self._frames, AVERAGE_OVER)
+
+    def compute_filtered(self, thermal):
+        return get_diff_back_filtered(
+            self.background,
+            thermal,
+            self.background_thresh,
+        )
+
+
+WINDOW_SIZE = 50
+MIN_FRAMES = 10 * 10  # 10 * 10  # 10seconds
+
+THRESHOLD = 12
+# maybe put to 12
+TRIGGER_FRAMES = 2
+
+
+class IRMotionDetector(MotionDetector):
+    def __init__(self, thermal_config, headers):
+        super().__init__(thermal_config, headers)
+        self.num_preview_frames = thermal_config.recorder.preview_secs * headers.fps
+        self.rgb_window = SlidingWindow(self.num_preview_frames, dtype=np.uint8)
+        self.gray_window = SlidingWindow(self.num_preview_frames, dtype=np.uint8)
+        # self._background = Background()
+        self._background = CVBackground()
+        self.kernel_trigger = np.ones(
+            (15, 15), "uint8"
+        )  # kernel for erosion when not recording
+        self.kernel_recording = np.ones(
+            (10, 10), "uint8"
+        )  # kernel for erosion when recording
+        self.movement_detected = False
+        self.triggered = 0
+        self.show = False
+        self.prev_triggered = False
+
+    def disconnected(self):
+        self.rgb_window.reset()
+        self.gray_window.reset()
+        self.processed = 0
+
+    @property
+    def calibrating(self):
+        return False
+
+    @property
+    def background(self):
+        return self._background.background
+
+    def get_kernel(self):
+        if self.movement_detected:
+            return self.kernel_recording
+        else:
+            return self.kernel_trigger
+
+    def preview_frames(self):
+        return self.rgb_window.get_frames()[:-1]
+
+    def get_recent_frame(self):
+        return self.rgb_window.current
+
+    # Processes a frame returning True if there is motion.
+    def process_frame(self, frame, force_process=False):
+        if self.can_record() or force_process:
+            self.rgb_window.add(frame)
+            gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
+            self.gray_window.add(gray)
+            #
+            if self.gray_window.oldest is None:
+                return False
+            learning_rate = 0 if self.movement_detected else -1
+            self._background.update_background(gray, learning_rate=learning_rate)
+            if self.num_frames > MIN_FRAMES:
+                # Filter and get diff from background
+                delta = cv2.absdiff(
+                    self.gray_window.oldest, gray
+                )  # Get delta from current frame and background
+
+                threshold = cv2.threshold(delta, THRESHOLD, 255, cv2.THRESH_BINARY)[1]
+                #
+                erosion_image = cv2.erode(threshold, self.get_kernel())
+                diff_erosion_pixels = len(erosion_image[erosion_image > 0])
+                erosion_image = cv2.erode(
+                    self._background.compute_filtered(None), self.get_kernel()
+                )
+                erosion_pixels = len(erosion_image[erosion_image > 0])
+
+                # assert erosion_pixels == self._background.detect_motion()
+                # if any have no pixels lets stop motion detection, wiht not updating
+                # background when motion is detected if the background actually changes
+                # this could cause a problem, this should catch that
+                if self.movement_detected:
+                    erosion_pixels = min(diff_erosion_pixels, erosion_pixels)
+                # to do find a value that suites the number of pixesl we want to move
+                # Calculate if there was motion in the current frame
+                # TODO Chenage how much ioldests added to the triggered depending on how big the motion is
+                self.prev_triggered = erosion_pixels > 0
+                if erosion_pixels > 0:
+                    self.triggered += 1
+                    self.triggered = min(self.triggered, 30)
+                else:
+                    self.triggered -= 1
+                    self.triggered = max(self.triggered, 0)
+                # Check if motion has started or ended
+                if not self.movement_detected and self.triggered >= TRIGGER_FRAMES:
+                    self.movement_detected = True
+
+                elif self.movement_detected and self.triggered <= 0:
+                    self.movement_detected = False
+        else:
+            self.rgb_window.update_current_frame(frame)
+        self.num_frames += 1
+        return self.movement_detected
+
+    @property
+    def temp_thresh(self):
+        return None
diff --git a/piclassifier/irrecorder.py b/piclassifier/irrecorder.py
index 1c355066..8a56223b 100644
--- a/piclassifier/irrecorder.py
+++ b/piclassifier/irrecorder.py
@@ -1,137 +1,123 @@
+import numpy as np
 from datetime import datetime
 import logging
 import os
 import yaml
-from load.cliptrackextractor import ClipTrackExtractor
-
+import multiprocessing
 from datetime import timedelta
 import time
 import psutil
-from piclassifier.recorder import Recorder
 import cv2
 from pathlib import Path
 
-AVI_TEMP_EXT = ".avi"
-TEMP_DIR = "temp"
+from ml_tools.mpeg_creator import MPEGCreator
+from piclassifier.recorder import Recorder
+from load.cliptrackextractor import ClipTrackExtractor
+from ml_tools.logs import init_logging
+from .eventreporter import log_event
 
-fourcc = cv2.VideoWriter_fourcc(*"XVID")
+
+VIDEO_EXT = ".mp4"
+
+# FOURCC = cv2.VideoWriter_fourcc(*"avc1")
+# JUST FOR TEST
+# VIDEO_EXT = ".avi"
+# FOURCC = cv2.VideoWriter_fourcc("M", "J", "P", "G")
+
+# gp this should work on pi, but causing lots of headaches
+# wont set bitrate, wont play back on ubuntu, should check again in future
+# as is very fast
+# CODEC = "h264_v4l2m2m"
+CODEC = "libx264"
+BITRATE = "1M"
+PIX_FMT = "yuv420p"
 
 
 class IRRecorder(Recorder):
-    def __init__(self, thermal_config, headers, on_recording_stopping=None):
-        self.location_config = thermal_config.location
-        self.device_config = thermal_config.device
-        self.output_dir = Path(thermal_config.recorder.output_dir)
-        self.motion = thermal_config.motion
-        self.preview_secs = thermal_config.recorder.preview_secs
-        self.writer = None
-        self.filename = None
-        self.recording = False
-        self.frames = 0
-        self.headers = headers
-        self.min_frames = thermal_config.recorder.min_secs * headers.fps
-        self.max_frames = thermal_config.recorder.max_secs * headers.fps
+    def __init__(
+        self,
+        thermal_config,
+        headers,
+        on_recording_stopping=None,
+        name="IRRecorder",
+        constant_recorder=False,
+    ):
+        super().__init__(
+            thermal_config,
+            headers,
+            name,
+            constant_recorder,
+            VIDEO_EXT,
+            on_recording_stopping,
+        )
         self.res_x = headers.res_x
         self.res_y = headers.res_y
         self.fps = headers.fps
-        self.write_until = 0
-        self.rec_time = 0
-        self.on_recording_stopping = on_recording_stopping
-        self.temp_dir = self.output_dir / TEMP_DIR
-        self.temp_dir.mkdir(parents=True, exist_ok=True)
-
-    def force_stop(self):
-        if not self.recording:
-            return
-        if self.has_minimum():
-            self.stop_recording(time.time())
-        else:
-            logging.info("Recording stopped early deleting short recording")
-            self.delete_recording()
-
-    def process_frame(self, movement_detected, cptv_frame):
-        if self.recording:
-            self.write_frame(cptv_frame)
-            if movement_detected:
-                self.write_until = self.frames + self.min_frames
-            elif self.has_minimum():
-                self.stop_recording(cptv_frame.received_at)
-                return
-
-            if self.frames == self.max_frames:
-                self.stop_recording(cptv_frame.received_at)
-
-    def has_minimum(self):
-        return self.frames > self.write_until
-
-    def start_recording(
-        self, background_frame, preview_frames, temp_thresh, frame_time
-    ):
-        start = time.time()
-        if self.recording:
-            logging.warn("Already recording, stop recording first")
-            return False
-        self.frames = 0
-        self.filename = new_temp_name(frame_time)
-
-        self.filename = self.temp_dir / self.filename
-        self.writer = cv2.VideoWriter(
-            str(self.filename), fourcc, self.fps, (self.res_x, self.res_y), 0
+
+    def new_recording(self, background_frame, preview_frames, temp_thresh, frame_time):
+        back = None
+        if background_frame is not None and len(background_frame.shape) == 2:
+            back = background_frame[:, :, np.newaxis]
+            back = np.repeat(back, 3, axis=2)
+        # preview_frames.insert(0, back)
+
+        self.rec_p = multiprocessing.Process(
+            target=record,
+            args=(
+                self.frame_q,
+                self.filename,
+                self.fps,
+                back,
+                preview_frames,
+                self.name,
+            ),
         )
-        self.writer.write(background_frame)
-        default_thresh = self.motion.temp_thresh
+        self.rec_p.start()
+        return True
 
-        self.recording = True
-        for frame in preview_frames:
-            self.write_frame(frame)
-        self.write_until = self.frames + self.min_frames
+    def final_name(self):
+        return self.output_dir / self.filename.name
 
-        logging.info("recording %s started temp_thresh: %d", self.filename, temp_thresh)
 
-        self.rec_time += time.time() - start
-        return True
+#
+# def new_temp_name(frame_time):
+#     return datetime.fromtimestamp(frame_time).strftime("%Y%m%d-%H%M%S.%f" + VIDEO_EXT)
+
+
+def write_frame(writer, frame):
+    yuv = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420)
+    writer.next_frame(yuv, frame.shape)
+
+
+def record(queue, filename, fps, background=None, init_frames=None, name="IRRecorder"):
+    init_logging()
+    frames = 0
+    try:
+        logging.info("%s Recorder %s started", name, filename.resolve())
 
-    def write_frame(self, cptv_frame):
-        start = time.time()
-        self.writer.write(cptv_frame.pix)
-        self.frames += 1
-        self.rec_time += time.time() - start
-
-    def stop_recording(self, frame_time):
-        start = time.time()
-        self.rec_time += time.time() - start
-        self.recording = False
-        logging.info(
-            "recording ended %s frames %s time recording %s per frame ",
-            self.frames,
-            self.rec_time,
-            self.rec_time / self.frames,
+        writer = MPEGCreator(
+            filename, fps=fps, codec=CODEC, bitrate=BITRATE, pix_fmt=PIX_FMT
         )
-        self.rec_time = 0
-
-        self.write_until = 0
-        if self.writer is None:
-            return
-        final_name = self.output_dir / self.filename.name
-
-        if self.on_recording_stopping is not None:
-            self.on_recording_stopping(final_name)
-        self.writer.release()
-        self.filename.rename(final_name)
-        # .rename(self.filename, final_name)
-        self.writer = None
-
-    def delete_recording(self):
-        self.recording = False
-        if self.writer is None:
-            return
-
-        self.writer.release()
-        self.filename.unlink()
-        self.writer = None
-
-
-def new_temp_name(frame_time):
-    return datetime.fromtimestamp(frame_time).strftime(
-        "%Y%m%d.%H%M%S.%f" + AVI_TEMP_EXT
-    )
+        if background is not None:
+            write_frame(writer, background)
+            frames += 1
+        if init_frames is not None:
+            for frame in init_frames:
+                write_frame(writer, frame)
+                frames += 1
+        while True:
+            frame = queue.get()
+            if isinstance(frame, int) and frame == 0:
+                writer.close()
+                break
+            write_frame(writer, frame)
+            frames += 1
+
+    except Exception as ex:
+        logging.error("%s Error Recording %s", name, filename.resolve(), exc_info=True)
+        log_event("error-recording", ex)
+        try:
+            writer.close()
+        except:
+            pass
+    logging.info("%s Recorder %s Written %s", name, filename.resolve(), frames)
diff --git a/piclassifier/motiondetector.py b/piclassifier/motiondetector.py
index 32ea2146..73516aac 100644
--- a/piclassifier/motiondetector.py
+++ b/piclassifier/motiondetector.py
@@ -1,26 +1,25 @@
+from abc import ABC, abstractmethod
 from threading import Lock
 from datetime import timedelta
 import logging
 import time
 import numpy as np
-from ml_tools.tools import Rectangle
-from .processor import Processor
 
 
 class SlidingWindow:
     def __init__(self, shape, dtype):
         self.lock = Lock()
-        if dtype == "O":
-            self.frames = [None] * shape[0]
-        else:
-            self.frames = np.empty(shape, dtype)
+        # if dtype == "O":
+        self.frames = [None] * shape
+        # else:
+        # self.frames = np.empty(shape, dtype)
         self.last_index = None
         self.size = len(self.frames)
         self.oldest_index = None
         self.non_ffc_index = None
         self.ffc = False
 
-    def update_current_frame(self, frame, ffc):
+    def update_current_frame(self, frame, ffc=False):
         with self.lock:
             if self.last_index is None:
                 self.oldest_index = 0
@@ -97,219 +96,124 @@ def reset(self):
             self.oldest_index = None
 
 
-class MotionDetector:
-    FFC_PERIOD = timedelta(seconds=9.9)
-    BACKGROUND_WEIGHT_ADD = 0.1
-
-    def __init__(self, thermal_config, dynamic_thresh, headers, detect_after=None):
-        self.headers = headers
-        if headers.model and headers.model.lower() == "lepton3.5":
-            MotionDetector.BACKGROUND_WEIGHT_ADD = 1
-        self.config = thermal_config.motion
-        self.location_config = thermal_config.location
-        self.preview_frames = thermal_config.recorder.preview_secs * headers.fps
-        self.compare_gap = self.config.frame_compare_gap + 1
-        edge = self.config.edge_pixels
-        self.min_frames = thermal_config.recorder.min_secs * headers.fps
-        self.max_frames = thermal_config.recorder.max_secs * headers.fps
-
-        if not self.config.one_diff_only:
-            self.diff_window = SlidingWindow(
-                (self.compare_gap, headers.res_y - edge * 2, headers.res_x - edge * 2),
-                np.int32,
-            )
-
-        self.thermal_window = SlidingWindow(
-            (self.preview_frames + 1, headers.res_y, headers.res_x), "O"
-        )
-        self.processed = 0
-        self.num_frames = 0
-        self.thermal_thresh = 0
-        self.background = None
-        self.last_background_change = None
-        self.background_weight = np.zeros(
-            (headers.res_y - edge * 2, headers.res_x - edge * 2)
-        )
+class MotionDetector(ABC):
+    def __init__(self, thermal_config, headers):
         self.movement_detected = False
-        self.dynamic_thresh = dynamic_thresh
-        self.temp_thresh = self.config.temp_thresh
-        self.crop_rectangle = Rectangle(
-            edge, edge, headers.res_x - 2 * edge, headers.res_y - 2 * edge
-        )
+        self.num_frames = 0
         self.rec_window = thermal_config.recorder.rec_window
+        self.location_config = thermal_config.location
         self.use_sunrise = self.rec_window.use_sunrise_sunset()
         self.last_sunrise_check = None
         self.location = None
         self.sunrise = None
         self.sunset = None
         self.recording = False
-        self.triggered = 0
+
         if self.rec_window.use_sunrise_sunset():
             self.rec_window.set_location(
                 *self.location_config.get_lat_long(use_default=True),
                 self.location_config.altitude,
             )
+        self.headers = headers
 
-        self.ffc_affected = False
-        if detect_after is None:
-            self.detect_after = self.thermal_window.size * 2
-        else:
-            self.detect_after = detect_after
-
-    def calc_temp_thresh(self, cropped_thermal):
-        edgeless_back = self.crop_rectangle.subimage(self.background)
-
-        if self.dynamic_thresh:
-            temp_changed = False
-            new_background = np.where(
-                edgeless_back < cropped_thermal - self.background_weight,
-                edgeless_back,
-                cropped_thermal,
-            )
-            # update weighting
-            self.background_weight = np.where(
-                edgeless_back < cropped_thermal - self.background_weight,
-                self.background_weight + MotionDetector.BACKGROUND_WEIGHT_ADD,
-                0,
-            )
-            back_changed = new_background != edgeless_back
-            back_changed = np.any(back_changed == True)
-            if back_changed:
-                self.last_background_change = self.processed
-                edgeless_back[:, :] = new_background
-                old_temp = self.temp_thresh
-                self.temp_thresh = int(round(np.average(edgeless_back)))
-                if self.temp_thresh != old_temp:
-                    logging.debug(
-                        "{} MotionDetector temp threshold changed from {} to {} ".format(
-                            self.num_frames,
-                            old_temp,
-                            self.temp_thresh,
-                        )
-                    )
-                    temp_changed = True
-                self.set_background_edges()
-        else:
-            self.temp_thresh = self.config.temp_thresh
+    @property
+    def res_x(self):
+        return self.headers.res_x
 
-    def detect(self, clipped_frame, received_at=None):
-        oldest = self.crop_rectangle.subimage(self.thermal_window.oldest_nonffc.pix)
-        oldest = np.clip(oldest, a_min=self.temp_thresh, a_max=None)
-        clipped_frame = np.clip(clipped_frame, a_min=self.temp_thresh, a_max=None)
-        delta_frame = clipped_frame - oldest
-        if not self.config.warmer_only:
-            delta_frame = abs(delta_frame)
-        if self.config.one_diff_only:
-            diff = len(delta_frame[delta_frame > self.config.delta_thresh])
-        else:
-            if self.processed > 2:
-                delta_frame2 = self.diff_window.oldest_nonffc
-                delta_frame[
-                    delta_frame >= self.config.delta_thresh
-                ] = self.config.delta_thresh
-                delta_combined = delta_frame2 + delta_frame
-                diff = len(
-                    delta_combined[delta_combined == self.config.delta_thresh * 2]
-                )
-            else:
-                delta_frame[
-                    delta_frame >= self.config.delta_thresh
-                ] = self.config.delta_thresh
-                diff = 0
+    @property
+    def res_y(self):
+        return self.headers.res_y
 
-            self.diff_window.add(delta_frame, self.ffc_affected)
-        if diff > self.config.count_thresh:
-            if not self.movement_detected:
-                logging.debug(
-                    "{} MotionDetector motion detected {} thresh {} count {} ".format(
-                        timedelta(seconds=self.num_frames / 9),
-                        self.processed,
-                        self.temp_thresh,
-                        diff,
-                    )
-                )
-            return True
+    @abstractmethod
+    def process_frame(self, clipped_frame, received_at=None):
+        """Tracker type IR or Thermal"""
 
-        if self.movement_detected:
-            logging.debug(
-                "{} MotionDetector motion stopped thresh {} count {}".format(
-                    timedelta(seconds=self.num_frames / 9),
-                    self.temp_thresh,
-                    diff,
-                )
-            )
-        return False
+    @abstractmethod
+    def preview_frames(self):
+        """Tracker type IR or Thermal"""
 
+    @abstractmethod
     def get_recent_frame(self):
-        return self.thermal_window.current
+        """Tracker type IR or Thermal"""
 
     def can_record(self):
         return self.rec_window.inside_window()
 
+    @abstractmethod
     def disconnected(self):
-        self.thermal_window.reset()
-        if not self.config.one_diff_only:
-            self.diff_window.reset()
-        self.processed = 0
+        """Tracker type IR or Thermal"""
 
-    def process_frame(self, cptv_frame, force_process=False):
-        prev_ffc = self.ffc_affected
-        self.ffc_affected = is_affected_by_ffc(cptv_frame)
-        if self.can_record() or force_process:
-            self.thermal_window.add(cptv_frame, self.ffc_affected)
+    @abstractmethod
+    def calibrating(self):
+        """Tracker type IR or Thermal"""
 
-            cropped_frame = np.int32(self.crop_rectangle.subimage(cptv_frame.pix))
-            if not self.ffc_affected:
-                if self.background is None:
-                    self.background = cptv_frame.pix
-                    self.last_background_change = self.processed
-                else:
-                    self.calc_temp_thresh(cropped_frame)
-            if self.ffc_affected or prev_ffc:
-                logging.debug("{} MotionDetector FFC".format(self.num_frames))
-                self.movement_detected = False
-                self.triggered = 0
-                if prev_ffc:
-                    self.thermal_window.non_ffc_index = self.thermal_window.last_index
-            elif self.processed > self.detect_after:
-                movement = self.detect(cropped_frame)
-                if movement:
-                    self.triggered += 1
-                else:
-                    self.triggered = 0
-                if self.triggered >= self.config.trigger_frames:
-                    self.movement_detected = True
-                else:
-                    self.movement_detected = False
-            self.processed += 1
-        else:
-            self.thermal_window.update_current_frame(cptv_frame, self.ffc_affected)
-            self.movement_detected = False
+    @property
+    @abstractmethod
+    def background(self):
+        """Tracker type IR or Thermal"""
+
+
+class WeightedBackground:
+    def __init__(
+        self, edge_pixels, crop_rectangle, res_x, res_y, weight_add, init_average
+    ):
+        self.edge_pixels = edge_pixels
+        self.crop_rectangle = crop_rectangle
+        self._background = None
+        self.weight_add = weight_add
+        self.background_weight = np.zeros(
+            (res_y - edge_pixels * 2, res_x - edge_pixels * 2)
+        )
+        self.average = init_average
 
-        self.num_frames += 1
+    def process_frame(self, frame):
+        if self._background is None:
+            res_y, res_x = frame.shape
+            self._background = np.empty(
+                (res_y + self.edge_pixels * 2, res_x + self.edge_pixels * 2)
+            )
+            self._background[
+                self.edge_pixels : res_y + self.edge_pixels,
+                self.edge_pixels : res_x + self.edge_pixels,
+            ] = frame
+            self.average = np.average(frame)
+            self.set_background_edges()
+
+            return
+        edgeless_back = self.crop_rectangle.subimage(self.background)
+        new_background = np.where(
+            edgeless_back < frame - self.background_weight,
+            edgeless_back,
+            frame,
+        )
+        # update weighting
+        self.background_weight = np.where(
+            edgeless_back < frame - self.background_weight,
+            self.background_weight + self.weight_add,
+            0,
+        )
+        back_changed = new_background != edgeless_back
+        back_changed = np.any(back_changed == True)
+        if back_changed:
+            edgeless_back[:, :] = new_background
+            old_temp = self.average
+            self.average = int(round(np.average(edgeless_back)))
+            if self.average != old_temp:
+                logging.debug(
+                    "MotionDetector temp threshold changed from {} to {} ".format(
+                        old_temp,
+                        self.average,
+                    )
+                )
+            self.set_background_edges()
 
     def set_background_edges(self):
-        edge_pixels = self.config.edge_pixels
-        for i in range(edge_pixels):
-            self.background[i] = self.background[edge_pixels]
-            self.background[-i - 1] = self.background[-edge_pixels - 1]
-            self.background[:, i] = self.background[:, edge_pixels]
-            self.background[:, -i - 1] = self.background[:, -1 - edge_pixels]
-
-    def skip_frame(self):
-        return
+        for i in range(self.edge_pixels):
+            self._background[i] = self._background[self.edge_pixels]
+            self._background[-i - 1] = self._background[-self.edge_pixels - 1]
+            self._background[:, i] = self._background[:, self.edge_pixels]
+            self._background[:, -i - 1] = self._background[:, -1 - self.edge_pixels]
 
     @property
-    def res_x(self):
-        return self.headers.res_x
-
-    @property
-    def res_y(self):
-        return self.headers.res_y
-
-
-def is_affected_by_ffc(cptv_frame):
-    if cptv_frame.time_on is None or cptv_frame.last_ffc_time is None:
-        return False
-
-    return (cptv_frame.time_on - cptv_frame.last_ffc_time) < MotionDetector.FFC_PERIOD
+    def background(self):
+        return self._background
diff --git a/piclassifier/piclassifier.py b/piclassifier/piclassifier.py
index 317f7830..ba9d49a6 100644
--- a/piclassifier/piclassifier.py
+++ b/piclassifier/piclassifier.py
@@ -13,33 +13,30 @@
 from load.irtrackextractor import IRTrackExtractor
 
 from load.cliptrackextractor import ClipTrackExtractor
-from ml_tools.preprocess import (
-    preprocess_segment,
-    preprocess_ir,
-    preprocess_frame,
-    preprocess_movement,
-)
+
 from ml_tools.previewer import Previewer, add_last_frame_tracking
 from ml_tools import tools
 from .cptvrecorder import CPTVRecorder
 from .throttledrecorder import ThrottledRecorder
 from .dummyrecorder import DummyRecorder
 from .irrecorder import IRRecorder
+from .irmotiondetector import IRMotionDetector
+from .cptvmotiondetector import CPTVMotionDetector
 
-from .motiondetector import MotionDetector, SlidingWindow
+from .motiondetector import SlidingWindow
 from .processor import Processor
-from ml_tools.preprocess import (
-    preprocess_frame,
-    preprocess_movement,
-)
 
 from ml_tools.interpreter import Interpreter
 from ml_tools.logs import init_logging
 from ml_tools.hyperparams import HyperParams
 from ml_tools.tools import CustomJSONEncoder
+from ml_tools.forestmodel import ForestModel
 from track.region import Region
 from . import beacon
 
+from piclassifier.trapcontroller import trigger_trap
+
+SNAPSHOT_SIGNAL = "snap"
 STOP_SIGNAL = "stop"
 SKIP_SIGNAL = "skip"
 track_extractor = None
@@ -47,47 +44,57 @@
 
 
 class NeuralInterpreter(Interpreter):
-    def __init__(self, model_name):
-        from openvino.inference_engine import IENetwork, IECore
+    TYPE = "Neural"
 
-        super().__init__(model_name)
+    def __init__(self, model_name, data_type):
+        from openvino.inference_engine import IENetwork, IECore
 
+        super().__init__(model_name, data_type)
+        model_name = Path(model_name)
         # can use to test on PC
         # device = "CPU"
         device = "MYRIAD"
-        model_xml = model_name + ".xml"
-        model_bin = os.path.splitext(model_xml)[0] + ".bin"
+        model_xml = model_name.with_suffix(".xml")
+        model_bin = model_name.with_suffix(".bin")
         ie = IECore()
         ie.set_config({}, device)
         net = ie.read_network(model=model_xml, weights=model_bin)
         self.input_blob = next(iter(net.input_info))
         self.out_blob = next(iter(net.outputs))
+        self.input_shape = net.input_info[self.input_blob].input_data.shape
+
         net.batch_size = 1
         self.exec_net = ie.load_network(network=net, device_name=device)
+        self.preprocess_fn = inc3_preprocess
 
     def predict(self, input_x):
         if input_x is None:
             return None
         input_x = np.float32(input_x)
-        rearranged_arr = np.transpose(input_x, axes=[2, 0, 1])
-        input_x = np.array([[rearranged_arr]])
+        channels_last = input_x.shape[-1] == 3
+        if channels_last:
+            input_x = np.moveaxis(input_x, 3, 1)
+        # input_x = np.transpose(input_x, axes=[3, 1, 2])
+        # input_x = np.array([[rearranged_arr]])
         res = self.exec_net.infer(inputs={self.input_blob: input_x})
         res = res[self.out_blob]
-        return res[0]
+        return res
 
     def shape(self):
-        return self.input_blob.shape
+        return self.input_shape
 
 
 class LiteInterpreter(Interpreter):
-    def __init__(self, model_name):
-        super().__init__(model_name)
+    TYPE = "TFLite"
 
-        import tensorflow as tf
+    def __init__(self, model_name, data_type):
+        super().__init__(model_name, data_type)
+
+        import tflite_runtime.interpreter as tflite
 
         model_name = Path(model_name)
         model_name = model_name.with_suffix(".tflite")
-        self.interpreter = tf.lite.Interpreter(str(model_name))
+        self.interpreter = tflite.Interpreter(str(model_name))
 
         self.interpreter.allocate_tensors()  # Needed before execution!
 
@@ -95,24 +102,30 @@ def __init__(self, model_name):
             0
         ]  # Model has single output.
         self.input = self.interpreter.get_input_details()[0]  # Model has single input.
+        self.preprocess_fn = inc3_preprocess
 
     def predict(self, input_x):
         start = time.time()
         input_x = np.float32(input_x)
-        input_x = input_x[np.newaxis, :]
+        # input_x = input_x[np.newaxis, :]
 
         self.interpreter.set_tensor(self.input["index"], input_x)
         self.interpreter.invoke()
-        pred = self.interpreter.get_tensor(self.output["index"])[0]
+        pred = self.interpreter.get_tensor(self.output["index"])
         logging.info("taken %s to predict", time.time() - start)
-
         return pred
 
     def shape(self):
         return self.input["shape"]
 
 
-def get_full_classifier(model):
+def inc3_preprocess(x):
+    x /= 127.5
+    x -= 1.0
+    return x
+
+
+def get_full_classifier(model, data_type):
     from ml_tools.kerasmodel import KerasModel
 
     """
@@ -123,22 +136,29 @@ def get_full_classifier(model):
     logging.info("classifier loading")
     classifier = KerasModel()
     classifier.load_model(model.model_file, weights=model.model_weights)
+    classifier.type = data_type
     logging.info("classifier loaded ({})".format(datetime.now() - t0))
 
     return classifier
 
 
-def get_classifier(model):
-    model_name, model_type = os.path.splitext(model.model_file)
-    logging.info("Loading %s", model_name)
-    if model_type == ".tflite":
-        classifier = LiteInterpreter(model_name)
-    elif model_type == ".xml":
-        classifier = NeuralInterpreter(model_name)
+def get_classifier(model, data_type):
+    # model_name, type = os.path.splitext(model.model_file)
+
+    logging.info(
+        "Loading %s of type %s with datatype %s",
+        model.model_file,
+        model.type,
+        data_type,
+    )
+    if model.type == LiteInterpreter.TYPE:
+        classifier = LiteInterpreter(model.model_file, data_type)
+    elif model.type == NeuralInterpreter.TYPE:
+        classifier = NeuralInterpreter(model.model_file, data_type)
+    elif model.type == ForestModel.TYPE:
+        classifier = ForestModel(model.model_file, data_type)
     else:
-        classifier = get_full_classifier(
-            model,
-        )
+        classifier = get_full_classifier(model, data_type)
     return classifier
 
 
@@ -159,10 +179,14 @@ def run_classifier(
                     return
                 if frame == "skip":
                     pi_classifier.skip_frame()
+                elif frame == SNAPSHOT_SIGNAL:
+                    pi_classifier.take_snapshot()
             else:
-                pi_classifier.process_frame(frame)
+                pi_classifier.process_frame(frame[0], frame[1])
     except:
         logging.error("Error running classifier restarting ..", exc_info=True)
+        pi_classifier.disconnected()
+        return
 
 
 predictions = None
@@ -176,7 +200,8 @@ class PiClassifier(Processor):
     MAX_CONSEC = 1
     # after every MAX_CONSEC frames skip this many frames
     # this gives the cpu a break
-    SKIP_FRAMES = 10
+    SKIP_FRAMES = 30
+    PREDICT_EVERY = 40
 
     def __init__(
         self,
@@ -187,6 +212,7 @@ def __init__(
         detect_after=None,
         preview_type=None,
     ):
+        self.constant_recorder = None
         self._output_dir = thermal_config.recorder.output_dir
         self.headers = headers
         super().__init__()
@@ -209,42 +235,83 @@ def __init__(
         self.bluetooth_beacons = thermal_config.motion.bluetooth_beacons
         self.preview_frames = thermal_config.recorder.preview_secs * headers.fps
 
-        self.fps_timer = SlidingWindow((9), np.float32)
+        self.fps_timer = SlidingWindow((headers.fps * 3), np.float32)
         self.preview_type = preview_type
         self.max_keep_frames = None if preview_type else 0
         if thermal_config.recorder.disable_recordings:
             self.recorder = DummyRecorder(
                 thermal_config, headers, on_recording_stopping
             )
-        if headers.model == "IR":
+
+        if headers.model == IRTrackExtractor.TYPE:
             logging.info("Running on IR")
+            PiClassifier.SKIP_FRAMES = 3
             self.track_extractor = IRTrackExtractor(
                 self.config.tracking,
-                config.use_opt_flow,
-                self.config.classify.cache_to_disk,
+                cache_to_disk=self.config.classify.cache_to_disk,
+                keep_frames=False,
                 verbose=config.verbose,
                 calc_stats=False,
                 scale=0.25,
+                on_trapped=on_track_trapped,
+                update_background=False,
+                trap_size=thermal_config.device_setup.trap_size,
             )
-            self.type = "IR"
+            self.tracking_config = self.track_extractor.config
+
+            self.type = IRTrackExtractor.TYPE
             if not thermal_config.recorder.disable_recordings:
                 self.recorder = IRRecorder(
                     thermal_config, headers, on_recording_stopping
                 )
+            self.snapshot_recorder = IRRecorder(
+                thermal_config, headers, on_recording_stopping, name="IR Snapshot"
+            )
+            self.motion_detector = IRMotionDetector(
+                thermal_config,
+                headers,
+            )
+            if thermal_config.recorder.constant_recorder:
+                self.constant_recorder = IRRecorder(
+                    thermal_config,
+                    headers,
+                    on_recording_stopping,
+                    name="IR Constant",
+                    constant_recorder=True,
+                )
         else:
             logging.info("Running on Thermal")
             self.track_extractor = ClipTrackExtractor(
                 self.config.tracking,
                 self.config.use_opt_flow,
                 self.config.classify.cache_to_disk,
+                keep_frames=False,
                 calc_stats=False,
             )
+            self.tracking_config = self.track_extractor.config
+
             self.type = "thermal"
             if not thermal_config.recorder.disable_recordings:
                 self.recorder = CPTVRecorder(
                     thermal_config, headers, on_recording_stopping
                 )
-        self.tracking_config = self.track_extractor.config
+            self.snapshot_recorder = CPTVRecorder(
+                thermal_config, headers, on_recording_stopping, name="CPTV Snapshot"
+            )
+            self.motion_detector = CPTVMotionDetector(
+                thermal_config,
+                self.tracking_config.motion.dynamic_thresh,
+                headers,
+                detect_after=detect_after,
+            )
+            if thermal_config.recorder.constant_recorder:
+                self.constant_recorder = CPTVRecorder(
+                    thermal_config,
+                    headers,
+                    on_recording_stopping,
+                    name="CPTV Constant",
+                    constant_recorder=True,
+                )
         edge = self.tracking_config.edge_pixels
         self.crop_rectangle = tools.Rectangle(
             edge, edge, headers.res_x - 2 * edge, headers.res_y - 2 * edge
@@ -258,30 +325,35 @@ def __init__(
             self.recorder = DummyRecorder(
                 thermal_config, headers, on_recording_stopping
             )
-        else:
-            self.recorder = CPTVRecorder(thermal_config, headers, on_recording_stopping)
         if thermal_config.throttler.activate:
             self.recorder = ThrottledRecorder(
                 self.recorder, thermal_config, headers, on_recording_stopping
             )
-
-        self.motion_detector = MotionDetector(
-            thermal_config,
-            self.tracking_config.motion.dynamic_thresh,
-            headers,
-            detect_after=detect_after,
-        )
         self.meta_dir = os.path.join(thermal_config.recorder.output_dir)
         if not os.path.exists(self.meta_dir):
             os.makedirs(self.meta_dir)
 
         if self.classify:
             model = config.classify.models[0]
-            self.classifier = get_classifier(model)
-            self.frames_per_classify = (
-                self.classifier.params.square_width
-                * self.classifier.params.square_width
-            )
+            self.classifier = get_classifier(model, self.type)
+
+            if self.classifier.TYPE == ForestModel.TYPE:
+                self.last_x_frames = 5 * headers.fps
+                self.frames_per_classify = self.last_x_frames
+                PiClassifier.SKIP_FRAMES = 30
+                # probably could be even more
+
+            else:
+                # self.preprocess_fn = self.get_preprocess_fn()
+
+                self.last_x_frames = 1
+                self.frames_per_classify = (
+                    self.classifier.params.square_width
+                    * self.classifier.params.square_width
+                )
+                if self.frames_per_classify > 1:
+                    self.last_x_frames = self.frames_per_classify * 2
+
             self.max_keep_frames = (
                 self.frames_per_classify * 2 if not preview_type else None
             )
@@ -294,70 +366,93 @@ def __init__(
                 self.fp_index = self.classifier.labels.index("false-positive")
             except ValueError:
                 self.fp_index = None
-            self.preprocess_fn = self.get_preprocess_fn()
             self.startup_classifier()
 
-    def get_preprocess_fn(self):
-        import tensorflow as tf
-
-        pretrained_model = self.classifier.params.model_name
-        if pretrained_model == "resnet":
-            return tf.keras.applications.resnet.preprocess_input
-
-        elif pretrained_model == "resnetv2":
-            return tf.keras.applications.resnet_v2.preprocess_input
-
-        elif pretrained_model == "resnet152":
-            return tf.keras.applications.resnet.preprocess_input
-
-        elif pretrained_model == "vgg16":
-            return tf.keras.applications.vgg16.preprocess_input
-
-        elif pretrained_model == "vgg19":
-            return tf.keras.applications.vgg19.preprocess_input
-
-        elif pretrained_model == "mobilenet":
-            return tf.keras.applications.mobilenet_v2.preprocess_input
-
-        elif pretrained_model == "densenet121":
-            return tf.keras.applications.densenet.preprocess_input
-
-        elif pretrained_model == "inceptionresnetv2":
-            return tf.keras.applications.inception_resnet_v2.preprocess_input
-        elif pretrained_model == "inceptionv3":
-            return tf.keras.applications.inception_v3.preprocess_input
-        logging.warn(
-            "pretrained model %s has no preprocessing function", pretrained_model
-        )
-        return None
+    #
+    # def get_preprocess_fn(self):
+    #     import tensorflow as tf
+    #
+    #     pretrained_model = self.classifier.params.model_name
+    #     if pretrained_model == "resnet":
+    #         return tf.keras.applications.resnet.preprocess_input
+    #
+    #     elif pretrained_model == "resnetv2":
+    #         return tf.keras.applications.resnet_v2.preprocess_input
+    #
+    #     elif pretrained_model == "resnet152":
+    #         return tf.keras.applications.resnet.preprocess_input
+    #
+    #     elif pretrained_model == "vgg16":
+    #         return tf.keras.applications.vgg16.preprocess_input
+    #
+    #     elif pretrained_model == "vgg19":
+    #         return tf.keras.applications.vgg19.preprocess_input
+    #
+    #     elif pretrained_model == "mobilenet":
+    #         return tf.keras.applications.mobilenet_v2.preprocess_input
+    #
+    #     elif pretrained_model == "densenet121":
+    #         return tf.keras.applications.densenet.preprocess_input
+    #
+    #     elif pretrained_model == "inceptionresnetv2":
+    #         return tf.keras.applications.inception_resnet_v2.preprocess_input
+    #     elif pretrained_model == "inceptionv3":
+    #         return tf.keras.applications.inception_v3.preprocess_input
+    #     logging.warn(
+    #         "pretrained model %s has no preprocessing function", pretrained_model
+    #     )
+    #     return None
 
     def new_clip(self, preview_frames):
         self.clip = Clip(
-            self.tracking_config, "stream", model=self.headers.model, type=self.type
+            self.tracking_config,
+            "stream",
+            model=self.headers.model,
+            type=self.type,
+            calc_stats=False,
+            fps=self.headers.fps,
         )
         global clip
         clip = self.clip
         self.clip.video_start_time = datetime.now()
         self.clip.num_preview_frames = self.preview_frames
+
         self.clip.set_res(self.res_x, self.res_y)
         self.clip.set_frame_buffer(
             self.tracking_config.high_quality_optical_flow,
             self.config.classify.cache_to_disk,
             self.config.use_opt_flow,
-            True,
-            self.max_keep_frames,
+            keep_frames=self.max_keep_frames > 0
+            if self.max_keep_frames is not None
+            else True,
+            max_frames=self.max_keep_frames,
         )
         edge_pixels = self.tracking_config.edge_pixels
 
         self.clip.update_background(self.motion_detector.background.copy())
         self.clip._background_calculated()
         # no need to retrack all of preview
-        self.track_extractor.start_tracking(self.clip, preview_frames)
+        background_frames = None
+        track_frames = -1
+        retrack_back = True
+        if self.type == IRTrackExtractor.TYPE:
+            track_frames = 5
+            retrack_back = False
+            # background is calculated in motion, so already 5 frames ahead
+        self.track_extractor.start_tracking(
+            self.clip,
+            preview_frames,
+            track_frames=track_frames,
+            background_alg=self.motion_detector._background,
+            retrack_back=retrack_back,
+            # background_frame=clip.background,
+            # background_frames=background_frames,
+        )
 
     def startup_classifier(self):
         # classifies an empty frame to force loading of the model into memory
         in_shape = self.classifier.shape()[1:]
-        p_frame = np.zeros((in_shape), np.float32)
+        p_frame = np.zeros((1, *in_shape), np.float32)
         self.classifier.predict(p_frame)
 
     def get_active_tracks(self):
@@ -365,7 +460,28 @@ def get_active_tracks(self):
         Gets current clips active_tracks and returns the top NUM_CONCURRENT_TRACKS order by priority
         """
         active_tracks = self.clip.active_tracks
-        active_tracks = [track for track in active_tracks if len(track) > 10]
+        active_tracks = [track for track in active_tracks if len(track) >= 8]
+        filtered = []
+        for track in active_tracks:
+            pred = None
+            if self.predictions is not None:
+                pred = self.predictions.prediction_for(track.get_id())
+            if pred is not None:
+                if (
+                    pred.last_frame_classified is not None
+                    and self.clip.current_frame - pred.last_frame_classified
+                    < PiClassifier.PREDICT_EVERY
+                ):
+                    logging.debug(
+                        "Skipping %s as predicted %s and now at %s",
+                        track,
+                        pred.last_frame_classified,
+                        self.clip.current_frame,
+                    )
+                    continue
+
+            filtered.append(track)
+        active_tracks = filtered
         if (
             len(active_tracks) <= PiClassifier.NUM_CONCURRENT_TRACKS
             or not self.classify
@@ -373,9 +489,7 @@ def get_active_tracks(self):
             return active_tracks
         active_predictions = []
         for track in active_tracks:
-            prediction = self.predictions.get_or_create_prediction(
-                track, keep_all=False
-            )
+            prediction = self.predictions.get_or_create_prediction(track, keep_all=True)
             active_predictions.append(prediction)
 
         top_priority = sorted(
@@ -410,20 +524,28 @@ def identify_last_frame(self):
 
         active_tracks = self.get_active_tracks()
         new_prediction = False
+        if len(active_tracks) == 0:
+            return False
+
+        logging.info("Identifying %s", active_tracks)
         for i, track in enumerate(active_tracks):
             regions = []
             track_prediction = self.predictions.get_or_create_prediction(
-                track, keep_all=False
+                track, keep_all=True
+            )
+            frames, prediction, mass = self.classifier.predict_track(
+                clip,
+                track,
+                last_x_frames=self.last_x_frames,
+                scale=self.track_extractor.scale,
+                frames_per_classify=self.frames_per_classify,
             )
-            if self.type == "IR":
-                prediction, mass = self.identify_ir(track)
-            else:
-                prediction, mass = self.identify_thermal(track)
             if prediction is None:
                 track_prediction.last_frame_classified = self.clip.current_frame
                 continue
-            track_prediction.classified_frame(self.clip.current_frame, prediction, mass)
-            logging.debug(
+            for p, m in zip(prediction, mass):
+                track_prediction.classified_frames(frames, p, m)
+            logging.info(
                 "Track %s is predicted as %s", track, track_prediction.get_prediction()
             )
 
@@ -458,79 +580,14 @@ def identify_last_frame(self):
                     if track_prediction:
                         active_predictions.append(track_prediction)
                 beacon.classification(active_predictions)
+        return True
 
-    def identify_ir(self, track):
-        region = track.bounds_history[-1]
-        frame = self.clip.frame_buffer.get_last_frame()
-        if frame is None:
-            return
-        params = self.classifier.params
-        preprocessed = preprocess_ir(
-            frame.copy(),
-            (
-                params.frame_size,
-                params.frame_size,
-            ),
-            region=region,
-            preprocess_fn=self.preprocess_fn,
-        )
-        if preprocessed is None:
-            return None, 1
-        prediction = self.classifier.predict(preprocessed)
-        return prediction, 1
-
-    def identify_thermal(self, track):
-        regions = track.bounds_history[-self.frames_per_classify * 2 :]
-        frames = self.clip.frame_buffer.get_last_x(len(regions))
-        if frames is None:
-            return
-        indices = np.random.choice(
-            len(regions),
-            min(self.frames_per_classify, len(regions)),
-            replace=False,
-        )
-        indices.sort()
-        frames = np.array(frames)[indices]
-        regions = np.array(regions)[indices]
-
-        refs = []
-        segment_data = []
-        mass = 0
-        params = self.classifier.params
-
-        for frame, region in zip(frames, regions):
-            if region.blank:
-                continue
-            refs.append(np.median(frame.thermal))
-            thermal_reference = np.median(frame.thermal)
-            f = frame.crop_by_region(region)
-            mass += region.mass
-            f.resize_with_aspect(
-                (params.frame_size, params.frame_size),
-                self.clip.crop_rectangle,
-                True,
-            )
-            segment_data.append(f)
-
-        preprocessed = preprocess_movement(
-            segment_data,
-            params.square_width,
-            params.frame_size,
-            red_type=params.red_type,
-            green_type=params.green_type,
-            blue_type=params.blue_type,
-            preprocess_fn=self.preprocess_fn,
-            reference_level=refs,
-            keep_edge=params.keep_edge,
-        )
-        if preprocessed is None:
-            return None, mass
-        prediction = self.classifier.predict(preprocessed)
-        return prediction, mass
-
-    def get_recent_frame(self):
+    def get_recent_frame(self, last_frame=None):
+        # save us having to lock if we dont have a different frame
+        if last_frame is not None and self.motion_detector.num_frames == last_frame:
+            return None, None, last_frame
+        last_frame = self.motion_detector.get_recent_frame()
         if self.clip:
-            last_frame = self.motion_detector.get_recent_frame()
             if last_frame is None:
                 return None
             track_meta = []
@@ -540,13 +597,16 @@ def get_recent_frame(self):
                 if self.predictions:
                     pred = {self.predictions.model.id: self.predictions}
                 meta = track.get_metadata(pred)
-                meta["positions"] = meta["positions"][-1:]
+                last_pos = meta["positions"][-1].copy()
+                # if self.track_extractor.scale is not None:
+                # last_pos.rescale(1 / self.track_extractor.scale)
+                meta["positions"] = [last_pos]
                 track_meta.append(meta)
 
             return last_frame, track_meta, self.motion_detector.num_frames
         else:
             return (
-                self.motion_detector.get_recent_frame(),
+                last_frame,
                 {},
                 self.motion_detector.num_frames,
             )
@@ -554,6 +614,10 @@ def get_recent_frame(self):
     def disconnected(self):
         self.motion_detector.disconnected()
         self.recorder.force_stop()
+        self.snapshot_recorder.force_stop()
+        if self.constant_recorder is not None:
+            self.constant_recorder.force_stop()
+
         self.end_clip()
         self.service.quit()
 
@@ -563,20 +627,45 @@ def skip_frame(self):
         if self.clip:
             self.clip.current_frame += 1
 
-    def process_frame(self, lepton_frame):
+    def take_snapshot(self):
+        started = self.snapshot_recorder.start_recording(
+            None, [], self.motion_detector.temp_thresh, time.time()
+        )
+        if not started:
+            logging.info("Already taking snapshot recording")
+            return False
+        logging.info("Taking new snapshot recorder")
+        self.snapshot_recorder.write_until = 2 * self.headers.fps
+        return True
+
+    def process_frame(self, lepton_frame, received_at):
+        import time
+
         start = time.time()
         self.motion_detector.process_frame(lepton_frame)
         self.process_time += time.time() - start
-
+        if self.snapshot_recorder.recording:
+            self.snapshot_recorder.process_frame(False, lepton_frame, received_at)
+        if self.constant_recorder is not None and self.motion_detector.can_record():
+            if self.constant_recorder.recording:
+                self.constant_recorder.process_frame(True, lepton_frame, received_at)
+            else:
+                logging.info("Starting new constant recorder")
+                self.constant_recorder.start_recording(
+                    self.motion_detector.background,
+                    [],
+                    self.motion_detector.temp_thresh,
+                    time.time(),
+                )
         if not self.recorder.recording and self.motion_detector.movement_detected:
             s_r = time.time()
-            preview_frames = self.motion_detector.thermal_window.get_frames()[:-1]
-
+            preview_frames = self.motion_detector.preview_frames()
+            bak = self.motion_detector.background
             recording = self.recorder.start_recording(
                 self.motion_detector.background,
                 preview_frames,
                 self.motion_detector.temp_thresh,
-                lepton_frame.received_at,
+                received_at,
             )
             self.rec_time += time.time() - s_r
             if recording:
@@ -588,9 +677,7 @@ def process_frame(self, lepton_frame):
 
         if self.recorder.recording:
             t_start = time.time()
-            self.track_extractor.process_frame(
-                self.clip, lepton_frame.pix, self.motion_detector.ffc_affected
-            )
+            self.track_extractor.process_frame(self.clip, lepton_frame)
             self.tracking_time += time.time() - t_start
             s_r = time.time()
             if self.tracking is not None:
@@ -616,27 +703,28 @@ def process_frame(self, lepton_frame):
                     self.tracking = None
 
             self.recorder.process_frame(
-                self.motion_detector.movement_detected, lepton_frame
+                self.motion_detector.movement_detected, lepton_frame, received_at
             )
             self.rec_time += time.time() - s_r
             if self.classify:
-                if self.motion_detector.ffc_affected or self.clip.on_preview():
+                if self.motion_detector.calibrating or self.clip.on_preview():
                     self.skip_classifying = PiClassifier.SKIP_FRAMES
                     self.classified_consec = 0
                 elif (
                     self.classify
-                    and self.motion_detector.ffc_affected is False
+                    and self.motion_detector.calibrating is False
                     and self.clip.active_tracks
                     and self.skip_classifying <= 0
                     and not self.clip.on_preview()
                 ):
                     id_start = time.time()
-                    self.identify_last_frame()
-                    self.identify_time += time.time() - id_start
-                    self.classified_consec += 1
-                    if self.classified_consec == PiClassifier.MAX_CONSEC:
-                        self.skip_classifying = PiClassifier.SKIP_FRAMES
-                        self.classified_consec = 0
+                    identified = self.identify_last_frame()
+                    if identified:
+                        self.identify_time += time.time() - id_start
+                        self.classified_consec += 1
+                        if self.classified_consec == PiClassifier.MAX_CONSEC:
+                            self.skip_classifying = PiClassifier.SKIP_FRAMES
+                            self.classified_consec = 0
                 else:
                     self.classified_consec = 0
             elif self.tracking is None and self.tracking_events:
@@ -676,23 +764,22 @@ def process_frame(self, lepton_frame):
             and self.frame_num % PiClassifier.DEBUG_EVERY == 0
         ):
             average = np.mean(self.fps_timer.get_frames())
-
+            mem = process_mem()
             logging.info(
-                "tracking {}% process {}%  identify {}% rec{}%s fps {}/sec  cpu % {} memory % {} behind by {} seconds".format(
-                    round(100 * self.tracking_time / self.total_time, 3),
-                    round(100 * self.process_time / self.total_time, 3),
-                    round(100 * self.identify_time / self.total_time, 3),
-                    round(100 * self.rec_time / self.total_time, 3),
-                    round(1 / average),
-                    psutil.cpu_percent(),
-                    psutil.virtual_memory()[2],
-                    time.time() - lepton_frame.received_at,
-                )
+                "tracking %s %% process %s %%  identify %s %% rec %s %% fps %s/sec process  system cpu %s process memory %s%% system memory %s behind by %s seconds",
+                round(100 * self.tracking_time / self.total_time, 3),
+                round(100 * self.process_time / self.total_time, 3),
+                round(100 * self.identify_time / self.total_time, 3),
+                round(100 * self.rec_time / self.total_time, 3),
+                round(1 / average),
+                psutil.cpu_percent(),
+                mem,
+                psutil.virtual_memory()[2],
+                time.time() - received_at,
             )
             self.tracking_time = 0
             self.process_time = 0
             self.identify_time = 0
-            self.motion_detector.rec_time = 0
             self.total_time = 0
             self.rec_time = 0
         self.fps_timer.add(time.time() - start)
@@ -710,14 +797,15 @@ def end_clip(self):
             if self.preview_type:
                 self.create_mp4()
             logging.debug(
-                "Ending clip with %s tracks pre filtering", len(self.clip.tracks)
+                "Ending clip with %s tracks post filtering", len(self.clip.tracks)
             )
             if self.classify:
-                for _, prediction in self.predictions.prediction_per_track.items():
+                for t_id, prediction in self.predictions.prediction_per_track.items():
                     if prediction.max_score:
                         logging.info(
-                            "Clip {} {}".format(
+                            "Clip {} {} {}".format(
                                 self.clip.get_id(),
+                                t_id,
                                 prediction.description(),
                             )
                         )
@@ -740,14 +828,38 @@ def output_dir(self):
         return self._output_dir
 
 
+def on_track_trapped(track):
+    track.trap_reported = True
+    # GP could make a prediction here
+
+    global predictions
+
+    tag = None
+    if predictions is not None:
+        pred = predictions.prediction_for(track.get_id())
+        if pred is not None:
+            tag = pred.predicted_tag()
+            track.trap_tag = tag
+    logging.warn("Trapped track %s with tag %s", track, tag)
+    trigger_trap(tag)
+
+
 def on_recording_stopping(filename):
     global clip, track_extractor, predictions
+
     if predictions is not None:
         for track_prediction in predictions.prediction_per_track.values():
             track_prediction.normalize_score()
 
     if clip and track_extractor:
         track_extractor.apply_track_filtering(clip)
+        # filter criteria has been scaled so resize after
+        # if track_extractor.scale is not None:
+        #     for track in clip.tracks:
+        #         for r in track.bounds_history:
+        #             # bring back to orignal size
+        #             r.rescale(1 / track_extractor.scale)
+
         meta_name = os.path.splitext(filename)[0] + ".txt"
         logging.debug("saving meta to %s", meta_name)
         predictions_per_model = None
@@ -764,3 +876,9 @@ def on_recording_stopping(filename):
 
         with open(meta_name, "w") as f:
             json.dump(meta_data, f, indent=4, cls=CustomJSONEncoder)
+
+
+def process_mem():
+    # return the memory usage in percentage like top
+    process = psutil.Process(os.getppid())
+    return process.memory_percent()
diff --git a/piclassifier/piclassify.py b/piclassifier/piclassify.py
index bfc1d623..76bc2604 100644
--- a/piclassifier/piclassify.py
+++ b/piclassifier/piclassify.py
@@ -1,19 +1,21 @@
 #!/usr/bin/python3
 import argparse
-from datetime import datetime
+from datetime import datetime, timedelta
 import logging
 import os
 import psutil
 import socket
 import time
 import cv2
+import json
 
 # fixes logging not showing up in tensorflow
 
 from cptv import CPTVReader
 import numpy as np
-import json
+from threading import Thread
 
+from config.timewindow import WindowStatus
 from config.config import Config
 from config.thermalconfig import ThermalConfig
 from .cptvrecorder import CPTVRecorder
@@ -26,7 +28,10 @@
 from .cameras.irframe import IRFrame
 import multiprocessing
 from cptv import Frame
+from .eventreporter import log_event
+from load.irtrackextractor import IRTrackExtractor
 
+from load.cliptrackextractor import ClipTrackExtractor
 
 SOCKET_NAME = "/var/run/lepton-frames"
 VOSPI_DATA_SIZE = 160
@@ -34,6 +39,7 @@
 STOP_SIGNAL = "stop"
 
 SKIP_SIGNAL = "skip"
+SNAPSHOT_SIGNAL = "snap"
 
 
 # TODO abstract interpreter class
@@ -52,7 +58,9 @@ def parse_args():
     parser.add_argument(
         "--thermal-config-file", help="Path to pi-config file (config.toml) to use"
     )
-
+    parser.add_argument(
+        "--ir", action="count", help="Path to pi-config file (config.toml) to use"
+    )
     args = parser.parse_args()
     return args
 
@@ -63,17 +71,49 @@ def main():
     args = parse_args()
 
     config = Config.load_from_file(args.config_file)
+    thermal_config = ThermalConfig.load_from_file(args.thermal_config_file)
+    thermal_config.recorder.rec_window.set_location(
+        *thermal_config.location.get_lat_long(use_default=True),
+        thermal_config.location.altitude,
+    )
+
     if args.file:
         return parse_file(
             args.file, config, args.thermal_config_file, args.preview_type
         )
 
+    process_queue = multiprocessing.Queue()
+
+    snapshot_thread = Thread(
+        target=take_snapshots,
+        args=(
+            thermal_config,
+            process_queue,
+        ),
+    )
+    snapshot_thread.start()
+
+    if args.ir or thermal_config.device_setup.ir:
+        while True:
+            try:
+                read = ir_camera(config, args.thermal_config_file, process_queue)
+                if read == 0:
+                    logging.error("Error reading camera try again in 10")
+                    time.sleep(10)
+                else:
+                    log_event("camera-disconnected", f"read {read} frames")
+            except Exception as ex:
+                log_event("camera-disconnected", ex)
+                logging.error("Error reading camera try again in 10", exc_info=True)
+                time.sleep(10)
+        return
+
     try:
         os.unlink(SOCKET_NAME)
     except OSError:
         if os.path.exists(SOCKET_NAME):
             raise
-
+    logging.info("running as thermal")
     sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
     sock.bind(SOCKET_NAME)
     sock.listen(1)
@@ -81,9 +121,13 @@ def main():
         logging.info("waiting for a connection")
         connection, client_address = sock.accept()
         logging.info("connection from %s", client_address)
+        log_event("camera-connected", {"type": ClipTrackExtractor.TYPE})
         try:
-            handle_connection(connection, config, args.thermal_config_file)
-        except:
+            handle_connection(
+                connection, config, args.thermal_config_file, process_queue
+            )
+        except Exception as ex:
+            log_event("camera-disconnected", ex)
             logging.error("Error with connection", exc_info=True)
             # return
         finally:
@@ -104,23 +148,27 @@ def parse_file(file, config, thermal_config_file, preview_type):
 
 
 def parse_ir(file, config, thermal_config_file, preview_type):
-    thermal_config = ThermalConfig.load_from_file(thermal_config_file, "IR")
+    thermal_config = ThermalConfig.load_from_file(
+        thermal_config_file, IRTrackExtractor.TYPE
+    )
+    from piclassifier import irmotiondetector
+
+    irmotiondetector.MIN_FRAMES = 0
     count = 0
     vidcap = cv2.VideoCapture(file)
     while True:
         success, image = vidcap.read()
         if not success:
             break
-        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
-        gray = cv2.resize(gray, (640, 360))
+        # gray = cv2.resize(gray, (640, 480))
         if count == 0:
-            res_y, res_x = gray.shape
+            res_y, res_x = image.shape[:2]
             headers = HeaderInfo(
                 res_x=res_x,
                 res_y=res_y,
                 fps=10,
                 brand=None,
-                model="IR",
+                model=IRTrackExtractor.TYPE,
                 frame_size=res_y * res_x,
                 pixel_bits=8,
                 serial="",
@@ -135,10 +183,18 @@ def parse_ir(file, config, thermal_config_file, preview_type):
                 0,
                 preview_type,
             )
-        frame = Frame(gray, None, None, None, None)
-        frame.received_at = time.time()
-
-        pi_classifier.process_frame(frame)
+            gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+            pi_classifier.motion_detector._background._background = np.float32(gray)
+            pi_classifier.motion_detector._background.update_background(
+                gray, learning_rate=1
+            )
+            pi_classifier.motion_detector._background._frames = 1000
+            count += 1
+            # assume this has been run over 1000 frames
+            continue
+        # frame = Frame(image, None, None, None, None)
+        # frame.received_at = time.time()
+        pi_classifier.process_frame(image, time.time())
         count += 1
     vidcap.release()
     pi_classifier.disconnected()
@@ -172,10 +228,10 @@ def parse_cptv(file, config, thermal_config_file, preview_type):
         )
         for frame in reader:
             if frame.background_frame:
-                pi_classifier.motion_detector.background = frame.pix
+                pi_classifier.motion_detector._background._background = frame.pix
                 continue
-            frame.received_at = time.time()
-            pi_classifier.process_frame(frame)
+            pi_classifier.process_frame(frame, time.time())
+
         pi_classifier.disconnected()
 
 
@@ -194,32 +250,143 @@ def get_processor(process_queue, config, thermal_config, headers):
 
 
 def handle_headers(connection):
-    headers = ""
+    headers = b""
     left_over = None
     while True:
-        data = connection.recv(4096).decode()
+        data = connection.recv(4096)
         headers += data
-        done = headers.find("\n\n")
+        done = headers.find(b"\n\n")
         if done > -1:
+            left_over = headers[done + 2 :]
             headers = headers[:done]
-            left_over = headers[done + 2 :].encode()
+            if left_over[:5] == b"clear":
+                left_over = left_over[5:]
             break
+    headers = headers.decode()
     return HeaderInfo.parse_header(headers), left_over
 
 
-def handle_connection(connection, config, thermal_config_file):
+def ir_camera(config, thermal_config_file, process_queue):
+    FPS = 10
+    logging.info("Starting ir video capture")
+    cap = cv2.VideoCapture(0)
+    cap.set(cv2.CAP_PROP_FPS, FPS)
+    frames = 0
+    try:
+        res_x = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
+        res_y = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
+        headers = HeaderInfo(
+            res_x=int(res_x),
+            res_y=int(res_y),
+            fps=FPS,
+            brand=None,
+            model=IRTrackExtractor.TYPE,
+            frame_size=res_y * res_x,
+            pixel_bits=8,
+            serial="",
+            firmware="",
+        )
+        thermal_config = ThermalConfig.load_from_file(
+            thermal_config_file, headers.model
+        )
+        processor = get_processor(process_queue, config, thermal_config, headers)
+        processor.start()
+        drop_frame = None
+        dropped = 0
+        start_dropping = None
+        while True:
+            returned, frame = cap.read()
+            if not processor.is_alive():
+                logging.info("Processor stopped, restarting %s", processor.is_alive())
+                processor = get_processor(
+                    process_queue, config, thermal_config, headers
+                )
+                processor.start()
+            if not returned:
+                logging.info("no frame from video capture")
+                process_queue.put(STOP_SIGNAL)
+                break
+            frames += 1
+            if frames == 1:
+                log_event("camera-connected", {"type": IRTrackExtractor.TYPE})
+            if drop_frame is not None and (frames - start_dropping) % drop_frame == 0:
+                logging.info("Dropping frame due to slow processing")
+                dropped += 1
+            else:
+                process_queue.put((frame, time.time()))
+            qsize = process_queue.qsize()
+            if qsize > headers.fps * 4 and (
+                drop_frame is None or frames > (start_dropping + drop_frame)
+            ):
+                # drop every 9th frame
+                if drop_frame is None:
+                    drop_frame = 9
+                else:
+                    drop_frame = drop_frame - 1
+                # drop first frame
+                start_dropping = frames + 1
+                logging.info("Dropping every %s frame as qsize %s", drop_frame, qsize)
+            elif qsize < headers.fps * 3:
+                drop_frame = None
+                start_dropping = None
+    finally:
+        time.sleep(5)
+        processor.terminate()
+    return frames
+
+
+def next_snapshot(thermal_config, prev_window_type=None):
+    window = thermal_config.recorder.rec_window
+    current_status = None
+    if prev_window_type is None:
+        current_status = window.window_status()
+
+    if current_status == WindowStatus.before or (
+        prev_window_type == WindowStatus.new or prev_window_type == WindowStatus.after
+    ):
+        started = window.next_start()
+        return (window.next_start(), WindowStatus.before)
+    elif not window.non_stop and (
+        current_status == WindowStatus.inside or prev_window_type == WindowStatus.before
+    ):
+        started = window.next_start()
+        if current_status is not None and started - datetime.now() < timedelta(
+            minutes=30
+        ):
+            logging.info("Started inside window within 30 mins")
+            return (started, WindowStatus.before)
+
+        return (window.next_end(), WindowStatus.inside)
+    else:
+        # next windowtimes
+        window.next_window()
+        return (window.next_start(), WindowStatus.before)
+
+
+def take_snapshots(thermal_config, process_queue):
+    next_snap = next_snapshot(thermal_config, None)
+    while True:
+        snap_time = next_snap[0] - timedelta(minutes=2)
+        time_until = (snap_time - datetime.now()).total_seconds()
+        if time_until > 0:
+            logging.info("Taking snapshot at %s", snap_time)
+            time.sleep(time_until)
+        logging.info("Sending snapshot signal")
+        process_queue.put(SNAPSHOT_SIGNAL)
+        next_snap = next_snapshot(thermal_config, next_snap[1])
+
+
+def handle_connection(connection, config, thermal_config_file, process_queue):
     headers, extra_b = handle_headers(connection)
     thermal_config = ThermalConfig.load_from_file(thermal_config_file, headers.model)
     logging.info(
         "parsed camera headers %s running with config %s", headers, thermal_config
     )
 
-    process_queue = multiprocessing.Queue()
-
     processor = get_processor(process_queue, config, thermal_config, headers)
     processor.start()
 
-    edge = config.tracking.edge_pixels
+    edge = config.tracking["thermal"].edge_pixels
     crop_rectangle = tools.Rectangle(
         edge, edge, headers.res_x - 2 * edge, headers.res_y - 2 * edge
     )
@@ -227,6 +394,12 @@ def handle_connection(connection, config, thermal_config_file):
     read = 0
     try:
         while True:
+            if not processor.is_alive():
+                logging.info("Processor stopped restarting")
+                processor = get_processor(
+                    process_queue, config, thermal_config, headers
+                )
+                processor.start()
             if extra_b is not None:
                 data = extra_b + connection.recv(
                     headers.frame_size - len(extra_b), socket.MSG_WAITALL
@@ -240,9 +413,11 @@ def handle_connection(connection, config, thermal_config_file):
                 process_queue.put(STOP_SIGNAL)
                 break
             try:
-                message = data[:5].decode("utf-8")
-                if message == "clear":
-                    logging.info("processing error from camera")
+                message = data[:5]
+                if message == b"clear":
+                    logging.info(
+                        "processing error from camera"
+                    )  # TODO Check if this is handled properly.
                     process_queue.put(STOP_SIGNAL)
                     break
             except:
@@ -251,20 +426,21 @@ def handle_connection(connection, config, thermal_config_file):
             frame = raw_frame.parse(data)
             frame.received_at = time.time()
             cropped_frame = crop_rectangle.subimage(frame.pix)
-            t_max = np.amax(cropped_frame)
             t_min = np.amin(cropped_frame)
             # seems to happen if pi is working hard
             if t_min == 0:
                 logging.warning(
-                    "received frame has odd values skipping thermal frame max {} thermal frame min {} cpu % {} memory % {}".format(
-                        t_max, t_min, psutil.cpu_percent(), psutil.virtual_memory()[2]
+                    "received frame has odd values skipping thermal frame min {} cpu % {} memory % {}".format(
+                        t_min, psutil.cpu_percent(), psutil.virtual_memory()[2]
                     )
                 )
+                log_event("bad-thermal-frame", f"Bad Pixel of {t_min}")
                 process_queue.put(SKIP_SIGNAL)
             elif read < 100:
                 process_queue.put(SKIP_SIGNAL)
             else:
-                process_queue.put(frame)
+                process_queue.put((frame, time.time()))
+
     finally:
         time.sleep(5)
         # give it a moment to close down properly
diff --git a/piclassifier/processor.py b/piclassifier/processor.py
index 68e82a19..a11422a2 100644
--- a/piclassifier/processor.py
+++ b/piclassifier/processor.py
@@ -26,14 +26,20 @@ class Processor(ABC):
     def __init__(
         self,
     ):
-        self.service = SnapshotService(self.get_recent_frame, self.headers)
+        self.service = SnapshotService(
+            self.get_recent_frame, self.headers, self.take_snapshot
+        )
+
+    @abstractmethod
+    def take_snapshot(self):
+        ...
 
     @abstractmethod
     def process_frame(self, lepton_frame):
         ...
 
     @abstractmethod
-    def get_recent_frame(self):
+    def get_recent_frame(self, last_frame=None):
         ...
 
     @abstractmethod
diff --git a/piclassifier/recorder.py b/piclassifier/recorder.py
index 4b0bcfbd..e94d7704 100644
--- a/piclassifier/recorder.py
+++ b/piclassifier/recorder.py
@@ -1,21 +1,199 @@
 from abc import ABC, abstractmethod
+from datetime import datetime
+import logging
+import multiprocessing
+import shutil
+import time
+from pathlib import Path
+
+from ml_tools.logs import init_logging
+
+TEMP_DIR = "temp"
 
 
 class Recorder(ABC):
-    @abstractmethod
-    def process_frame(self, movement_detected, cptv_frame):
-        ...
+    def __init__(
+        self,
+        thermal_config,
+        headers,
+        name,
+        constant_recorder,
+        file_extention,
+        on_recording_stopping=None,
+    ):
+        self.file_extention = file_extention
+        self.name = name
+        self.constant_recorder = constant_recorder
+        self.location_config = thermal_config.location
+        self.device_config = thermal_config.device
+        self.output_dir = Path(thermal_config.recorder.output_dir)
+        if constant_recorder:
+            self.output_dir = self.output_dir / "constant-recordings"
+            self.output_dir.mkdir(parents=True, exist_ok=True)
+
+        self.temp_dir = self.output_dir / TEMP_DIR
+        self.temp_dir.mkdir(parents=True, exist_ok=True)
+        self.motion = thermal_config.motion
+        self.preview_secs = thermal_config.recorder.preview_secs
+        self.filename = None
+        self.recording = False
+        self.frames = 0
+        self.headers = headers
+        self.min_disk_space = thermal_config.recorder.min_disk_space
+        self.min_frames = thermal_config.recorder.min_secs * headers.fps
+        self.max_frames = thermal_config.recorder.max_secs * headers.fps
+        self.min_recording = self.preview_secs * headers.fps + self.min_frames
+        self.write_until = 0
+        self.rec_time = 0
+        self.on_recording_stopping = on_recording_stopping
+        self.frame_q = multiprocessing.Queue()
+        self.rec_p = None
+
+    def process_frame(self, movement_detected, cptv_frame, received_at):
+        if self.recording:
+            self.write_frame(cptv_frame)
+            if movement_detected:
+                self.write_until = self.frames + self.min_frames
+            elif self.has_minimum():
+                self.stop_recording(received_at)
+                return
+
+            if self.frames == self.max_frames:
+                self.stop_recording(received_at)
+
+    def has_minimum(self):
+        return self.frames >= self.write_until
+
+    def write_frame(self, frame):
+        start = time.time()
+        self.frame_q.put(frame)
+        # self.writer.next_frame(frame)
+        self.frames += 1
+        self.rec_time += time.time() - start
+
+    def can_record(self, frame_time):
+        stat = shutil.disk_usage(self.output_dir)
+        free = stat[2] * 0.000001
+        if free < self.min_disk_space:
+            logging.warn(
+                "%s cannot record as only have %s MB and need %s MB",
+                self.name,
+                free,
+                self.min_disk_space,
+            )
+        return free > self.min_disk_space
+
+    def force_stop(self):
+        if not self.recording:
+            return
+        if self.frames > self.min_recording:
+            self.stop_recording(time.time())
+        else:
+            logging.info(
+                "%s Recording stopped early deleting short recording", self.name
+            )
+            self.delete_recording()
+
+    def delete_recording(self):
+        if self.recording:
+            self.frame_q.put(0)
+            self.rec_p.join()
+            self.frame_q = multiprocessing.Queue()
+            self.rec_p = None
+            self.recording = False
+        self.filename.unlink()
+
+    def stop_recording(self, frame_time):
+        start = time.time()
+        self.rec_time += time.time() - start
+        self.recording = False
+        final_name = self.final_name()
+        logging.info("%s Waiting for recorder to finish", self.name)
+        self.frame_q.put(0)
+        self.rec_p.join()
+        self.frame_q = multiprocessing.Queue()
+        self.rec_p = None
+        logging.info(
+            "%s recording %s ended %s frames %s time recording %s per frame ",
+            self.name,
+            final_name,
+            self.frames,
+            self.rec_time,
+            self.rec_time / self.frames if self.frames > 0 else 0,
+        )
+        self.rec_time = 0
+        self.write_until = 0
+        # write metadata first
+        logging.info("Stopped %s", final_name)
+        if self.on_recording_stopping is not None:
+            self.on_recording_stopping(final_name)
+
+        self.filename.rename(final_name)
+
+    def delete_excess(self):
+        stat = shutil.disk_usage(self.output_dir)
+        free_percent = stat[2] / stat[0]
+        if free_percent >= 0.3:
+            return
+        recordings = list(self.output_dir.glob(f"*{self.file_extention}"))
+        recordings.sort()
+        if len(recordings) == 0:
+            return
+        while free_percent < 0.3 and len(recordings) > 0:
+            logging.info("Deleting %s", recordings[0])
+            recordings[0].unlink()
+            meta = recordings[0].with_suffix(".txt")
+            if meta.exists:
+                logging.info("Deleting %s", meta)
+                meta.unlink()
+            recordings = recordings[1:]
+            stat = shutil.disk_usage(self.output_dir)
+            free_percent = stat[2] / stat[0]
 
-    @abstractmethod
     def start_recording(
         self, background_frame, preview_frames, temp_thresh, frame_time
     ):
-        ...
+        if self.constant_recorder:
+            self.delete_excess()
+
+        start = time.time()
+        if self.recording:
+            logging.warn("%s Already recording, stop recording first", self.name)
+            return False
+
+        if not self.can_record(frame_time):
+            # logging.warn("%s Cannot record", self.name)
+            return False
+        self.frames = 0
+
+        self.filename = self.new_temp_name(frame_time)
+        started = self.new_recording(
+            background_frame, preview_frames, temp_thresh, frame_time
+        )
+        if not started:
+            return False
+        self.rec_time = time.time() - start
+        self.write_until = self.frames + self.min_frames
+        self.recording = True
+
+        logging.info(
+            "%s recording %s started temp_thresh: %s",
+            self.name,
+            self.filename,
+            temp_thresh,
+        )
+
+        return True
+
+    def new_temp_name(self, frame_time):
+        return self.temp_dir / datetime.fromtimestamp(frame_time).strftime(
+            "%Y%m%d-%H%M%S.%f" + self.file_extention
+        )
 
     @abstractmethod
-    def stop_recording(self, frame_time):
+    def new_recording(self, background_frame, preview_frames, temp_thresh, frame_time):
         ...
 
     @abstractmethod
-    def force_stop(self):
+    def final_name(self):
         ...
diff --git a/piclassifier/run-classifier.sh b/piclassifier/run-classifier.sh
index 059eecd2..a164c246 100755
--- a/piclassifier/run-classifier.sh
+++ b/piclassifier/run-classifier.sh
@@ -1,3 +1,9 @@
 #!/bin/bash
-source /opt/intel/openvino_2021/bin/setupvars.sh
-/home/pi/classifier/bin/python3 /home/pi/classifier-pipeline/piclassify.py
+
+set -e # Exit if any commands fail
+
+file_path="/opt/intel/openvino_2022/setupvars.sh"
+if [ -f "$file_path" ]; then
+  source "$file_path"
+fi
+/home/pi/classifier/bin/python3 /home/pi/classifier-pipeline/piclassify.py -c /home/pi/classifier-pipeline/pi-classifier.yaml
diff --git a/piclassifier/throttledrecorder.py b/piclassifier/throttledrecorder.py
index 987d1b76..1051db35 100644
--- a/piclassifier/throttledrecorder.py
+++ b/piclassifier/throttledrecorder.py
@@ -21,29 +21,61 @@ def __init__(self, recorder, thermal_config, headers, on_recording_stopping):
         )
         self.min_recording = self.recorder.min_frames
         self.throttled_at = None
+        self.constant_recorder = False
+
+    def final_name(self):
+        return self.recorder.final_name()
+
+    @property
+    def name(self):
+        return self.recorder.name
+
+    @property
+    def filename(self):
+        return self.recorder.filename
+
+    @filename.setter
+    def filename(self, a):
+        self.recorder.filename = a
 
     @property
     def recording(self):
         return self.recorder.recording
 
+    @recording.setter
+    def recording(self, a):
+        self.recorder.recording = a
+
+    @property
+    def write_until(self):
+        return self.recorder.write_until
+
+    @write_until.setter
+    def write_until(self, a):
+        self.recorder.write_until = a
+
+    @property
+    def min_frames(self):
+        return self.recorder.min_frames
+
     def force_stop(self):
         if self.recorder.recording:
             self.last_rec = time.time()
         self.recorder.force_stop()
 
-    def process_frame(self, movement_detected, cptv_frame):
+    def process_frame(self, movement_detected, cptv_frame, received_at):
         if movement_detected:
-            self.last_motion = cptv_frame.received_at
+            self.last_motion = received_at
 
         was_recording = self.recorder.recording
-        self.recorder.process_frame(movement_detected, cptv_frame)
-        self.take_token(cptv_frame.received_at)
+        self.recorder.process_frame(movement_detected, cptv_frame, received_at)
+        self.take_token(received_at)
         if was_recording and not self.recorder.recording:
-            self.last_rec = cptv_frame.received_at
+            self.last_rec = received_at
 
         if self.throttling and self.recorder.recording:
             logging.info("Throttling recording")
-            self.stop_recording(cptv_frame.received_at)
+            self.stop_recording(received_at)
 
     def update_tokens(self, frame_time):
         if self.last_motion is None:
@@ -80,7 +112,7 @@ def update_tokens(self, frame_time):
                 else:
                     return
             else:
-                self.tokens = since_motion * self.fps
+                self.tokens += since_motion * self.fps
                 logging.debug(
                     "Updating tokens %s seconds since motion has earnt %s tokens",
                     round(since_motion),
@@ -95,11 +127,10 @@ def update_tokens(self, frame_time):
             self.tokens += since_motion * self.fps
         self.throttling = False
         self.throttled_at = None
+        self.tokens = int(self.tokens)
         self.tokens = min(self.tokens, self.bucket_size)
 
-    def start_recording(
-        self, background_frame, preview_frames, temp_thresh, frame_time
-    ):
+    def new_recording(self, background_frame, preview_frames, temp_thresh, frame_time):
         logging.debug("Attempting rec have %s tokens", self.tokens)
         self.update_tokens(frame_time)
         self.last_motion = frame_time
@@ -110,16 +141,39 @@ def start_recording(
             self.throttle(frame_time)
             return False
         self.take_token(frame_time, len(preview_frames))
-        self.recorder.start_recording(
+        self.recorder.new_recording(
             background_frame, preview_frames, temp_thresh, frame_time
         )
         return True
 
+    def can_record(self, frame_time):
+        prev_throttled = self.throttling
+        self.update_tokens(frame_time)
+        logging.debug(
+            "Attempting rec have %s tokens was throttled %s throttled  %s tokens %s",
+            self.tokens,
+            prev_throttled,
+            self.throttling,
+            self.tokens,
+        )
+        self.last_motion = frame_time
+        if self.throttling:
+            if not prev_throttled:
+                throttled_event()
+            return False
+        if self.tokens < self.min_recording:
+            self.throttle(frame_time)
+            return False
+        return True
+
     def stop_recording(self, frame_time):
         if self.recorder.recording:
             self.last_rec = frame_time
             self.recorder.stop_recording(frame_time)
 
+    def new_temp_name(self, frame_time):
+        return self.recorder.new_temp_name(frame_time)
+
     def throttle(self, frame_time):
         logging.info("Throttling")
         self.throttling = True
@@ -128,5 +182,6 @@ def throttle(self, frame_time):
 
     def take_token(self, frame_time, num_tokens=1):
         self.tokens -= num_tokens
-        if self.tokens == 0:
+        if self.tokens <= 0:
+            self.tokens = 0
             self.throttle(frame_time)
diff --git a/piclassifier/trapcontroller.py b/piclassifier/trapcontroller.py
new file mode 100644
index 00000000..d9041be1
--- /dev/null
+++ b/piclassifier/trapcontroller.py
@@ -0,0 +1,19 @@
+import time
+import dbus
+import logging
+import json
+
+DBUS_NAME = "org.cacophony.TrapController"
+DBUS_PATH = "/org/cacophony/TrapController"
+
+
+def trigger_trap(tag=None):
+    bus = dbus.SystemBus()
+    try:
+        proxy = bus.get_object(DBUS_NAME, DBUS_PATH)
+        data = {}
+        if tag is not None:
+            data["tag"] = tag
+        proxy.TriggerTrap(json.dumps(data))
+    except:
+        logging.error("trapped dbus error ", exc_info=True)
diff --git a/pirequirements.txt b/pirequirements.txt
index 74b9fc2b..54fce06e 100644
--- a/pirequirements.txt
+++ b/pirequirements.txt
@@ -1,24 +1,27 @@
-tensorflow~=1.14
-matplotlib~=3.0
-pytz
-cptv~=1.5.3
-opencv-contrib-python-headless~=4.5.1.48
-numpy~=1.21.3
-scipy~=1.4.1
+#install opencv via sudo apt install python3-opencv libglib2.0-dev libgirepository1.0-dev libcairo2-dev libcairo2  python3-dbus build-essential libdbus-glib-1-dev libgirepository1.0-dev
+
+tflite-runtime==2.11.0
+matplotlib==3.6.3
+pytz==2022.7.1
+cptv==1.5.4
+numpy==1.23.1
+scipy==1.9.3
 python-dateutil
-sklearn
-tables~=3.4
-h5py~=3.1.0
-pyyaml>=4.2b1
-pillow~=6.2.0
-attrs~=19.1
-filelock~=3.0.12
-Astral~=1.10.1
-timezonefinder~=4.1.0
-portalocker~=1.5.2
-toml~=0.10.0
-cacophonyapi
-psutil~=5.6.7
-#used when running on the pi (Still under construction)
-PyGObject~=3.34.0
-dbus-python
+scikit-learn==1.1.3
+tables==3.8.0
+h5py==3.8.0
+pyyaml==6.0
+pillow==9.2.0
+attrs==19.2.0
+filelock==3.0.12
+Astral==1.10.1
+timezonefinder==4.1.0
+portalocker==1.5.2
+toml==0.10.2
+cacophonyapi==0.0.2
+psutil==5.6.7
+#sudo apt install libcairo2 libcairo2-dev
+PyGObject==3.44.0
+dbus-python==1.3.2
+importlib_resources==5.10.2
+opencv-python==4.8.0.76
diff --git a/service.py b/service.py
index 8c989936..8e0cb240 100644
--- a/service.py
+++ b/service.py
@@ -8,16 +8,20 @@
 import dbus.mainloop.glib
 from gi.repository import GLib
 from ml_tools.tools import CustomJSONEncoder
+from cptv import Frame
+
+from dbus.mainloop.glib import DBusGMainLoop
 
 DBUS_NAME = "org.cacophony.thermalrecorder"
 DBUS_PATH = "/org/cacophony/thermalrecorder"
 
 
 class Service(dbus.service.Object):
-    def __init__(self, dbus, get_frame, headers):
+    def __init__(self, dbus, get_frame, headers, take_snapshot_fn):
         super().__init__(dbus, DBUS_PATH)
         self.get_frame = get_frame
         self.headers = headers
+        self.take_snapshot = take_snapshot_fn
 
     @dbus.service.method(
         DBUS_NAME,
@@ -27,6 +31,7 @@ def __init__(self, dbus, get_frame, headers):
     def CameraInfo(self):
         logging.debug("Serving headers %s", self.headers)
         headers = self.headers.as_dict()
+        ir = headers.get("model") == "IR"
         for k, v in headers.items():
             try:
                 headers[k] = int(v)
@@ -36,7 +41,10 @@ def CameraInfo(self):
         headers["FPS"] = headers.get("fps", 9)
         headers["ResX"] = headers.get("res_x", 160)
         headers["ResY"] = headers.get("res_y", 120)
-
+        if ir:
+            headers["Model"] = 2
+        else:
+            headers["Model"] = 1
         logging.debug("Sending headers %s", headers)
         return headers
 
@@ -47,16 +55,23 @@ def CameraInfo(self):
     )
     def TakeSnapshot(self, last_num):
         s = time.time()
-        last_frame, track_meta, f_num = self.get_frame()
+        last_frame, track_meta, f_num = self.get_frame(last_num)
+
+        if f_num == last_num or last_frame is None:
+            return (np.empty((0, 0)), (0, "", f_num, 0, 0, 0, 0, False), "")
         logging.debug(
             "Frame requested %s latest frame %s took %s",
             last_num,
             f_num,
             time.time() - s,
         )
-
-        if f_num == last_num or last_frame is None:
-            return (np.empty((0, 0)), (0, "", f_num, 0, 0, 0, 0, False), "")
+        if not isinstance(last_frame, Frame):
+            last_frame = last_frame[:, :, 0]
+            return (
+                last_frame,
+                (0, "", f_num, 0, 0, 0, 0, 0),  # count
+                json.dumps(track_meta, cls=CustomJSONEncoder),
+            )
         return (
             last_frame.pix,
             (
@@ -72,18 +87,32 @@ def TakeSnapshot(self, last_num):
             json.dumps(track_meta, cls=CustomJSONEncoder),
         )
 
+    @dbus.service.method(
+        DBUS_NAME,
+    )
+    def TakeTestRecording(self):
+        logging.info("Take test recording")
+        result = False
+        try:
+            result = self.take_snapshot()
+        except:
+            logging.error("Error taking test recording", exc_info=True)
+
+        return result
+
     @dbus.service.signal(DBUS_NAME, signature="siaib")
     def Tracking(self, what, confidence, region, tracking):
         pass
 
 
 class SnapshotService:
-    def __init__(self, get_frame, headers):
-        dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
+    def __init__(self, get_frame, headers, take_snapshot_fn):
+        DBusGMainLoop(set_as_default=True)
+        dbus.mainloop.glib.threads_init()
         self.loop = GLib.MainLoop()
         self.t = threading.Thread(
             target=self.run_server,
-            args=(get_frame, headers),
+            args=(get_frame, headers, take_snapshot_fn),
         )
         self.t.start()
         self.service = None
@@ -91,14 +120,10 @@ def __init__(self, get_frame, headers):
     def quit(self):
         self.loop.quit()
 
-    def run_server(self, get_frame, headers):
-        session_bus = dbus.SystemBus()
+    def run_server(self, get_frame, headers, take_snapshot_fn):
+        session_bus = dbus.SystemBus(mainloop=DBusGMainLoop())
         name = dbus.service.BusName(DBUS_NAME, session_bus)
-        self.service = Service(session_bus, get_frame, headers)
-        # tracking_thread = threading.Thread(
-        #     target=self.send_tracks,
-        # )
-        # tracking_thread.start()
+        self.service = Service(session_bus, get_frame, headers, take_snapshot_fn)
         self.loop.run()
 
     def tracking(self, what, confidence, region, tracking):
@@ -109,5 +134,7 @@ def tracking(self, what, confidence, region, tracking):
             region,
             tracking,
         )
+        if self.service is None:
+            return
         self.service.Tracking(what, round(100 * confidence), region, tracking)
         # time.sleep(10)
diff --git a/tfliteconverter.py b/tfliteconverter.py
index c192929c..8db4378a 100644
--- a/tfliteconverter.py
+++ b/tfliteconverter.py
@@ -47,6 +47,9 @@ def convert_model(args):
         #     tf.lite.OpsSet.TFLITE_BUILTINS,  # enable TensorFlow Lite ops.
         #     tf.lite.OpsSet.SELECT_TF_OPS,  # enable TensorFlow ops.
         # ]
+        # 8 bit ingeter
+        converter.optimizations = [tf.lite.Optimize.DEFAULT]
+
         tflite_model = converter.convert()
         print("saving model to ", lite_dir / args.tflite_name)
         lite_dir.mkdir(parents=True, exist_ok=True)
diff --git a/track/cliptracker.py b/track/cliptracker.py
index 6adb04da..0cba686d 100644
--- a/track/cliptracker.py
+++ b/track/cliptracker.py
@@ -7,7 +7,7 @@
 
 from track.track import Track
 from track.region import Region
-from ml_tools.imageprocessing import detect_objects, normalize
+from ml_tools.imageprocessing import detect_objects, normalize, hist_diff
 
 
 class ClipTracker(ABC):
@@ -19,8 +19,12 @@ def __init__(
         calc_stats=True,
         verbose=False,
         do_tracking=True,
+        scale=None,
     ):
         config = config.get(self.type)
+        self.scale = scale
+        # if scale:
+        # config.rescale(scale)
         self.do_tracking = do_tracking
         self.verbose = verbose
         self.config = config
@@ -61,7 +65,7 @@ def tracking_time(self):
 
     @property
     @abstractmethod
-    def start_tracking(self, clip, preview_frames):
+    def start_tracking(self, clip, preview_frames, track_frames=True):
         """start_tracking"""
         ...
 
@@ -126,7 +130,7 @@ def _match_existing_tracks(self, clip, regions):
         scores.sort(key=lambda record: record[0])
         matched_tracks = set()
         blanked_tracks = set()
-
+        cur_frame = clip.frame_buffer.current_frame
         for score, track, region in scores:
             if (
                 track in matched_tracks
@@ -135,26 +139,45 @@ def _match_existing_tracks(self, clip, regions):
             ):
                 continue
             logging.debug(
-                "matched %s with %s to track %s", clip.current_frame, region, track
+                "frame# %s matched %s to track %s", clip.current_frame, region, track
             )
             used_regions.add(region)
             unmatched_regions.remove(region)
-            if not self.config.filter_regions_pre_match and (
-                region.pixel_variance < self.config.aoi_pixel_variance
-                or region.mass < self.config.aoi_min_mass
-            ):
-                # this will force a blank frame to be added, rather than if we filter earlier
-                # and match this track to a different region
-                logging.debug(
-                    "%s filtering region %s because of variance %s and mass %s track %s",
-                    region.frame_number,
-                    region,
-                    region.pixel_variance,
-                    region.mass,
-                    track,
-                )
-                blanked_tracks.add(track)
-                continue
+            if not self.config.filter_regions_pre_match:
+                if self.config.min_hist_diff is not None:
+                    background = clip.background
+                    # if self.scale:
+                    #     background = clip.rescaled_background(
+                    #         (int(self.res_x), int(self.res_y))
+                    #     )
+                    hist_v = hist_diff(region, background, cur_frame.thermal)
+                    if hist_v > self.config.min_hist_diff:
+                        logging.warn(
+                            "%s filtering region %s because of hist diff %s track %s ",
+                            region.frame_number,
+                            region,
+                            hist_v,
+                            track,
+                        )
+
+                        blanked_tracks.add(track)
+                        continue
+                if (
+                    region.pixel_variance < self.config.aoi_pixel_variance
+                    or region.mass < self.config.aoi_min_mass
+                ):
+                    # this will force a blank frame to be added, rather than if we filter earlier
+                    # and match this track to a different region
+                    logging.debug(
+                        "%s filtering region %s because of variance %s and mass %s track %s",
+                        region.frame_number,
+                        region,
+                        region.pixel_variance,
+                        region.mass,
+                        track,
+                    )
+                    blanked_tracks.add(track)
+                    continue
             track.add_region(region)
             matched_tracks.add(track)
 
@@ -196,7 +219,7 @@ def _filter_inactive_tracks(self, clip, unactive_tracks):
             track.add_blank_frame()
             if track.tracking:
                 clip.active_tracks.add(track)
-                logging.info(
+                logging.debug(
                     "frame {} adding a blank frame to {} ".format(
                         clip.current_frame, track.get_id()
                     )
@@ -223,7 +246,6 @@ def _get_regions_of_interest(self, clip, component_details, centroids=None):
         :param filtered: The filtered frame
         :return: regions of interest, mask frame
         """
-
         delta_thermal, delta_filtered = self.get_delta_frame(
             clip,
         )
@@ -252,6 +274,8 @@ def _get_regions_of_interest(self, clip, component_details, centroids=None):
                 frame_number=clip.current_frame,
                 centroid=centroid,
             )
+            if self.scale:
+                region.rescale(1 / self.scale)
             # GP this needs to be checked for themals 29/06/2022
             if clip.type == "IR":
                 if delta_thermal is not None:
@@ -436,3 +460,177 @@ def filter_track(self, clip, track, stats):
     def print_if_verbose(self, info_string):
         if self.verbose:
             logging.info(info_string)
+
+
+class Background(ABC):
+    TRIGGER_FRAMES = 2
+
+    def __init__(self):
+        self.rescaled = None
+        self.prev_triggered = False
+        self.triggered = 0
+        self.movement_detected = False
+        self.kernel_trigger = np.ones(
+            (15, 15), "uint8"
+        )  # kernel for erosion when not recording
+        self.kernel_recording = np.ones(
+            (10, 10), "uint8"
+        )  # kernel for erosion when recording
+
+    @abstractmethod
+    def set_background(self, background, frames=1):
+        """set_background version"""
+        ...
+
+    @abstractmethod
+    def update_background(self, thermal, filtered):
+        """update_background version"""
+        ...
+
+    @abstractmethod
+    def compute_filtered(self, thermal, threshold):
+        """compute_filtered version"""
+        ...
+
+    @property
+    @abstractmethod
+    def background(self):
+        """Background image"""
+        ...
+
+    @property
+    @abstractmethod
+    def frames(self):
+        """frames used"""
+        ...
+
+    @property
+    def frames(self):
+        return self._frames
+
+    def get_kernel(self):
+        if self.movement_detected:
+            return self.kernel_recording
+        else:
+            return self.kernel_trigger
+
+    def detect_motion(self):
+        fg = self.compute_filtered(None)
+        erosion_image = cv2.erode(fg, self.get_kernel())
+        erosion_pixels = len(erosion_image[erosion_image > 0])
+
+        self.prev_triggered = erosion_pixels > 0
+        if erosion_pixels > 0:
+            self.triggered += 1
+            self.triggered = min(self.triggered, 2)
+        else:
+            self.triggered -= 1
+            self.triggered = max(self.triggered, 0)
+        self.movement_detected = self.triggered >= Background.TRIGGER_FRAMES
+        return self.movement_detected
+
+
+class CVBackground(Background):
+    def __init__(self, use_subsense=False):
+        super().__init__()
+        self.use_subsense = use_subsense
+        # knn doesnt respect learning rate, but maybe mog2 is better anyway
+        if use_subsense:
+            import pybgs as bgs
+
+            self.algorithm = bgs.SuBSENSE()
+        else:
+            self.algorithm = cv2.createBackgroundSubtractorMOG2(
+                history=1000, detectShadows=False
+            )
+            # print(self.algorithm.getBackgroundRatio(), "RATION")
+            # 1 / 0
+        # self.algorithm = cv2.createBackgroundSubtractorKNN(
+        #     history=1000, detectShadows=False
+        # )
+        self._frames = 0
+        self._background = None
+
+    def set_background(self, background, frames=1):
+        # seems to be better to do x times rather than just set background
+        if self.use_subsense:
+            for _ in range(10):
+                # doesnt have a learning rate
+                self.update_background(background, learning_rate=1)
+        else:
+            self.update_background(background, learning_rate=1)
+
+            # return
+
+    def update_background(self, thermal, filtered=None, learning_rate=-1):
+        if self.use_subsense:
+            self._background = self.algorithm.apply(thermal)
+        else:
+            self._background = self.algorithm.apply(thermal, None, learning_rate)
+
+        self._frames += 1
+
+    @property
+    def background(self):
+        if self.use_subsense:
+            return self.algorithm.getBackgroundModel()
+        else:
+            return self.algorithm.getBackgroundImage()
+
+    def compute_filtered(self, thermal):
+        return self._background
+
+
+class DiffBackground(Background):
+    def __init__(self, background_thresh):
+        super().__init__()
+        self._frames = 1
+        self._background = None
+        self.background_thresh = background_thresh
+
+    def set_background(self, background, frames=1):
+        self._frames = frames
+        self._background = np.float32(background) * self.frames
+        return
+
+    def update_background(self, thermal):
+        background = self.background
+        filtered = get_diff_back_filtered(
+            self.background,
+            thermal,
+            self.background_thresh,
+        )
+        new_thermal = np.where(filtered > 0, background, thermal)
+        self._background += new_thermal
+        self._frames += 1
+
+    def compute_filtered(self, thermal=None):
+        filtered = get_diff_back_filtered(
+            self.background,
+            thermal,
+            self.background_thresh,
+        )
+        return filtered
+
+    @property
+    def background(self):
+        return self._background / self.frames
+
+    @property
+    def frames(self):
+        return self._frames
+
+
+def get_diff_back_filtered(background, frame, back_thresh):
+    """
+    Calculates filtered frame from thermal
+    :param frame: the frame
+    :param background: (optional) used for background subtraction
+    :return: uint8 filtered frame and adjusted clip threshold for normalized frame
+    """
+
+    filtered = np.float32(frame.copy())
+    filtered = abs(filtered - background)
+    filtered[filtered < back_thresh] = 0
+    filtered, stats = normalize(filtered, new_max=255)
+    return filtered
diff --git a/track/framebuffer.py b/track/framebuffer.py
index 6d1cd475..92a20fea 100644
--- a/track/framebuffer.py
+++ b/track/framebuffer.py
@@ -95,10 +95,12 @@ def remove_cache(self):
             self.cache.delete()
 
     def get_frame_ago(self, n=1):
-        if n == 1:
+        if n == 0:
+            return self.current_frame
+        elif n == 1:
             return self.get_last_frame()
-        if len(self.frames) >= n:
-            return self.frames[-n]
+        if len(self.frames) > n:
+            return self.frames[-(n + 1)]
         return None
 
     def get_last_frame(self):
diff --git a/track/region.py b/track/region.py
index 3a558024..397332ae 100644
--- a/track/region.py
+++ b/track/region.py
@@ -39,6 +39,15 @@ class Region(Rectangle):
     was_cropped = attr.ib(default=False)
     blank = attr.ib(default=False)
     is_along_border = attr.ib(default=False)
+    in_trap = attr.ib(default=False)
+
+    def rescale(self, factor):
+        self.x = int(self.x * factor)
+        self.y = int(self.y * factor)
+
+        self.width = int(self.width * factor)
+        self.height = int(self.height * factor)
+        self.mass = self.mass * (factor**2)
 
     @staticmethod
     def from_ltwh(left, top, width, height):
@@ -138,17 +147,6 @@ def calculate_variance(self, filtered, prev_filtered):
         self.pixel_variance = tools.calculate_variance(filtered, prev_filtered)
 
     def set_is_along_border(self, bounds, edge=0):
-        logging.info(
-            "Set is along border %s,%s,%s,%s bounds %s,%s,%s,%s",
-            self.x,
-            self.y,
-            self.right,
-            self.bottom,
-            bounds.x,
-            bounds.y,
-            bounds.width,
-            bounds.height,
-        )
         self.is_along_border = (
             self.was_cropped
             or self.x <= bounds.x + edge
@@ -181,17 +179,17 @@ def average_distance(self, other):
 
         expected_x = int(other.x)
         expected_y = int(other.y)
-        distance = tools.eucl_distance((expected_x, expected_y), (self.x, self.y))
+        distance = tools.eucl_distance_sq((expected_x, expected_y), (self.x, self.y))
         distances.append(distance)
 
         expected_x = int(other.mid_x)
         expected_y = int(other.mid_y)
-        distance = tools.eucl_distance(
+        distance = tools.eucl_distance_sq(
             (expected_x, expected_y), (self.mid_x, self.mid_y)
         )
         distances.append(distance)
 
-        distance = tools.eucl_distance(
+        distance = tools.eucl_distance_sq(
             (
                 other.right,
                 other.bottom,
@@ -200,7 +198,7 @@ def average_distance(self, other):
         )
         # expected_x = int(other.right)
         # expected_y = int(other.bottom)
-        # distance = tools.eucl_distance((expected_x, expected_y), (self.x, self.y))
+        # distance = tools.eucl_distance_sq((expected_x, expected_y), (self.x, self.y))
         distances.append(distance)
 
         return distances
diff --git a/track/track.py b/track/track.py
index 79c14427..dd5df716 100644
--- a/track/track.py
+++ b/track/track.py
@@ -24,7 +24,7 @@
 from ml_tools.tools import Rectangle
 from track.region import Region
 from kalman.kalman import Kalman
-from ml_tools.tools import eucl_distance
+from ml_tools.tools import eucl_distance_sq
 from ml_tools.datasetstructures import get_segments, SegmentHeader, SegmentType
 import cv2
 import logging
@@ -65,6 +65,7 @@ class RegionTracker(Tracker):
 
     def __init__(self, id, tracking_config, crop_rectangle=None):
         self.track_id = id
+        self.clear_run = 0
         self.kalman_tracker = Kalman()
         self._frames_since_target_seen = 0
         self.frames = 0
@@ -146,12 +147,12 @@ def match(self, regions, track):
                 max_distances = max_distances[:1]
 
             if max_mass_change and abs(avg_mass - region.mass) > max_mass_change:
-                logging.info(
-                    "track {} region mass {} deviates too much from {}".format(
-                        track.get_id(),
-                        region.mass,
-                        avg_mass,
-                    )
+                logging.debug(
+                    "track %s region mass %s deviates too much from %s for region %s",
+                    track.get_id(),
+                    region.mass,
+                    avg_mass,
+                    region,
                 )
                 continue
             skip = False
@@ -159,10 +160,12 @@ def match(self, regions, track):
                 if max_distance is None:
                     continue
                 if distance > max_distance:
-                    logging.info(
-                        "track {} distance score {} bigger than max distance {}".format(
-                            track.get_id(), distance, max_distance
-                        )
+                    logging.debug(
+                        "track %s distance score %s bigger than max distance %s for region %s",
+                        track.get_id(),
+                        distance,
+                        max_distance,
+                        region,
                     )
                     skip = True
                     break
@@ -171,10 +174,12 @@ def match(self, regions, track):
                 continue
 
             if size_change > max_size_change:
-                logging.info(
-                    "track {} size_change {} bigger than max size_change {}".format(
-                        track.get_id(), size_change, max_size_change
-                    )
+                logging.debug(
+                    "track % size_change %s bigger than max size_change %s for region %s",
+                    track.get_id(),
+                    size_change,
+                    max_size_change,
+                    region,
                 )
                 continue
             # only for thermal
@@ -193,12 +198,14 @@ def add_region(self, region):
             self._blank_frames += 1
             self._frames_since_target_seen += 1
             stop_tracking = min(
-                2 * (self.frames - self.blank_frames),
+                2 * (self.frames - self._frames_since_target_seen),
                 self.max_blanks,
             )
             self._tracking = self._frames_since_target_seen < stop_tracking
-
         else:
+            if self._frames_since_target_seen != 0:
+                self.clear_run = 0
+            self.clear_run += 1
             self._tracking = True
             self.kalman_tracker.correct(region)
             self._frames_since_target_seen = 0
@@ -304,7 +311,6 @@ def get_max_size_change(track, region):
     if len(track) < 5:
         # may increase at first
         region_percent = 2
-    logging.info("Track %s entering %s exiting %s", track, entering, exiting)
     vel = np.sum(np.abs(track.velocity))
     if entering or exiting:
         region_percent = 2
@@ -340,7 +346,11 @@ def __init__(
         Creates a new Track.
         :param id: id number for track, if not specified is provided by an auto-incrementer
         """
-
+        self.in_trap = False
+        self.trap_reported = False
+        self.trigger_frame = None
+        self.direction = 0
+        self.trap_tag = None
         if not id:
             self._id = Track._track_id
             Track._track_id += 1
@@ -348,7 +358,6 @@ def __init__(
             self._id = id
         self.clip_id = clip_id
         self.start_frame = None
-        self.end_frame = None
         self.start_s = None
         self.end_s = None
         self.fps = fps
@@ -543,7 +552,7 @@ def load_track_meta(
                         raise Exception("No frame number info for track")
             if self.start_frame is None:
                 self.start_frame = region.frame_number
-            self.end_frame = region.frame_number
+            # self.end_frame = region.frame_number
             self.bounds_history.append(region)
             self.frame_list.append(region.frame_number)
         self.current_frame_num = 0
@@ -573,7 +582,7 @@ def add_region(self, region):
                 self.add_blank_frame()
         self.tracker.add_region(region)
         self.bounds_history.append(region)
-        self.end_frame = region.frame_number
+        # self.end_frame = region.frame_number
         self.prev_frame_num = region.frame_number
         self.update_velocity()
 
@@ -687,7 +696,7 @@ def get_stats(self):
             if region.has_moved(self.bounds_history[i - 1]) or region.is_along_border:
                 distance = (vx**2 + vy**2) ** 0.5
                 movement += distance
-                offset = eucl_distance(first_point, region.mid)
+                offset = eucl_distance_sq(first_point, region.mid)
                 max_offset = max(max_offset, offset)
                 frames_moved += 1
         avg_vel = avg_vel / len(mass_history)
@@ -799,7 +808,7 @@ def trim(self):
         filter_mass = 0.005 * median_mass
         filter_mass = max(filter_mass, 2)
         start = 0
-        logging.info(
+        logging.debug(
             "Triming track with median % and filter mass %s", median_mass, filter_mass
         )
         while start < len(self) and mass_history[start] <= filter_mass:
@@ -866,6 +875,21 @@ def set_end_s(self, fps):
     def predicted_velocity(self):
         return self.tracker.predicted_velocity()
 
+    def update_trapped_state(self):
+        if self.in_trap:
+            return self.in_trap
+        min_frames = 2
+        if len(self.bounds_history) < min_frames:
+            return False
+        self.in_trap = all(r.in_trap for r in self.bounds_history[-min_frames:])
+        return self.in_trap
+
+    @property
+    def end_frame(self):
+        if len(self.bounds_history) == 0:
+            return 0
+        return self.bounds_history[-1].frame_number
+
     @property
     def nonblank_frames(self):
         return self.end_frame + 1 - self.start_frame - self.blank_frames
@@ -903,6 +927,11 @@ def get_metadata(self, predictions_per_model=None):
         start_s, end_s = self.start_and_end_in_secs()
 
         track_info["id"] = self.get_id()
+        if self.in_trap:
+            track_info["trap_triggered"] = self.in_trap
+            track_info["trigger_frame"] = self.trigger_frame
+            if self.trap_tag is not None:
+                track_info["trap_tag"] = self.trap_tag
         track_info["tracker_version"] = self.tracker_version
         track_info["start_s"] = round(start_s, 2)
         track_info["end_s"] = round(end_s, 2)
diff --git a/track/trackextractor.py b/track/trackextractor.py
index e185bcc1..b6235829 100644
--- a/track/trackextractor.py
+++ b/track/trackextractor.py
@@ -13,7 +13,7 @@
 from load.clip import Clip
 from load.cliptrackextractor import ClipTrackExtractor
 from load.irtrackextractor import IRTrackExtractor
-
+from pathlib import Path
 from ml_tools import tools
 from ml_tools.previewer import Previewer
 from track.track import Track
@@ -101,13 +101,14 @@ def extract_file(filename):
     :param filename: filename to process
     :param enable_preview: if true an MPEG preview file is created.
     """
+    filename = Path(filename)
     global config
     global cache_to_disk
-    if not os.path.exists(filename):
+    if not filename.is_file():
         raise Exception("File {} not found.".format(filename))
     logging.info("Processing file '{}'".format(filename))
     previewer = Previewer.create_if_required(config, config.classify.preview)
-    extension = os.path.splitext(filename)[1]
+    extension = filename.suffix
     if extension == ".cptv":
         track_extractor = ClipTrackExtractor(
             config.tracking,
@@ -143,17 +144,10 @@ def extract_file(filename):
     if not success:
         logging.error("Could not parse %s", filename)
         return
-    out_file = get_output_file(filename)
-    destination_folder = os.path.dirname(out_file)
-    if not os.path.exists(destination_folder):
-        logging.info("Creating folder {}".format(destination_folder))
-        os.makedirs(destination_folder)
-    meta_filename = out_file + ".txt"
+    meta_filename = filename.with_suffix(".txt")
 
     if previewer:
-        base_name = os.path.basename(out_file)
-        mpeg_filename = destination_folder + "/" + base_name + "-tracking.mp4"
-
+        mpeg_filename = filename.parent / f"{filename.stem}-tracking.mp4"
         previewer.export_clip_preview(mpeg_filename, clip)
     logging.info("saving meta data %s", meta_filename)
 
@@ -181,7 +175,7 @@ def save_metadata(filename, meta_filename, clip, track_extractor, config):
         # if no tracks choose a clip thumb
         region = best_trackless_thumb(clip)
         metadata["thumbnail_region"] = region
-    metadata["source"] = filename
+    metadata["source"] = str(filename)
     metadata["tracking_time"] = round(track_extractor.tracking_time, 1)
     metadata["algorithm"] = {}
     metadata["algorithm"]["tracker_version"] = track_extractor.tracker_version
diff --git a/train/train.py b/train/train.py
index 5cb846d9..a9ed0b1f 100644
--- a/train/train.py
+++ b/train/train.py
@@ -61,7 +61,8 @@ def train_model(
             epochs=conf.train.epochs,
             run_name=run_name + "_" + "TEST",
             weights=weights,
-            resample=True,
+            resample=False,
+            rebalance=True,
         )
     except KeyboardInterrupt:
         pass


Test patch:
diff --git a/config/test_timewindow.py b/config/test_timewindow.py
index 86e06c5f..8becaf2d 100644
--- a/config/test_timewindow.py
+++ b/config/test_timewindow.py
@@ -16,20 +16,20 @@ def test_defaults(self):
         assert rel_time.is_after()
         assert not rel_time.is_before()
 
-        rel_time = RelAbsTime(None, default_time=cur_date.time())
+        rel_time = RelAbsTime(None, default_time=cur_date)
         assert rel_time.is_after()
         assert not rel_time.is_before()
         assert rel_time.offset_s is None
 
     def test_offset(self):
         cur_date = datetime.now()
-        rel_time = RelAbsTime("30m", default_time=cur_date.time())
+        rel_time = RelAbsTime("30m", default_time=cur_date)
         assert rel_time.offset_s == 30 * 60
         rel_time = RelAbsTime("30s", default_offset=200)
         assert rel_time.offset_s == 30
         rel_time = RelAbsTime("1.5h")
         assert rel_time.offset_s == 1.5 * 60 * 60
-        rel_time = RelAbsTime("1a.d5h", default_time=cur_date.time())
+        rel_time = RelAbsTime("1a.d5h", default_time=cur_date)
         assert rel_time.offset_s is None
         assert rel_time.time == cur_date.time()
 
@@ -75,5 +75,38 @@ def test_sunrise_times(self):
         time_window.inside_window()
         assert time_window.last_sunrise_check is not None
 
-        time_window.end.time = (cur_date + timedelta(hours=11)).time()
+        time_window.end.dt = cur_date + timedelta(hours=11)
         assert time_window.inside_window()
+
+        start = RelAbsTime(cur_date.strftime("0s"))
+        end = RelAbsTime("0s")
+        time_window = TimeWindow(start, end)
+        time_window.set_location(TestWindow.DEFAULT_LAT, TestWindow.DEFAULT_LONG, 0)
+
+        time_window.update_sun_times()
+        assert time_window.start.dt.date() == datetime.now().date()
+        assert time_window.end.dt > time_window.start.dt
+        assert time_window.end.dt.date() == datetime.now().date() + timedelta(days=1)
+        prev_s = time_window.start.dt.date()
+        time_window.update_sun_times()
+        assert time_window.start.dt.date() == prev_s
+
+        time_window.update_sun_times(True)
+        assert time_window.start.dt.date() == prev_s + timedelta(days=1)
+
+        time_window.inside_window()
+        assert time_window.start.dt.date() == prev_s + timedelta(days=1)
+
+        time_window = TimeWindow(start, end)
+        time_window.set_location(TestWindow.DEFAULT_LAT, TestWindow.DEFAULT_LONG, 0)
+        time_window.update_sun_times()
+        prev_s = time_window.start.dt.date()
+        time_window.inside_window()
+
+        time_window.start.dt = datetime.now() - timedelta(days=1)
+        time_window.end.dt = datetime.now() - timedelta(days=1)
+        prev_s = time_window.start.dt.date()
+
+        # current time is after start and end so sould pick tomorrows sunset window
+        in_w = time_window.inside_window()
+        assert prev_s != time_window.start.dt.date()
diff --git a/motiontest.py b/motiontest.py
index b317ae9b..acc87813 100644
--- a/motiontest.py
+++ b/motiontest.py
@@ -4,7 +4,7 @@
 
 from config.config import Config
 from ml_tools.logs import init_logging
-from piclassifier.motiondetector import MotionDetector
+from piclassifier.cptvmotiondetector import CPTVMotionDetector
 from config.thermalconfig import ThermalConfig
 from piclassifier.headerinfo import HeaderInfo
 
@@ -44,7 +44,7 @@ def main():
             firmware="",
         )
 
-        motion_detector = MotionDetector(
+        motion_detector = CPTVMotionDetector(
             thermal_config, config.tracking["thermal"].motion.dynamic_thresh, headers
         )
         for i, frame in enumerate(reader):
diff --git a/tests/config.toml b/tests/config.toml
index dcc9ef58..d7bc23cf 100644
--- a/tests/config.toml
+++ b/tests/config.toml
@@ -29,7 +29,7 @@
   use-sunrise-sunset = false
   max-secs = 300
   min-disk-space-mb = 200
-  min-secs = 10
+  min-secs = 5
   output-dir = "/var/spool/cptv"
   preview-secs = 1
 
diff --git a/tests/trackingtest.py b/tests/trackingtest.py
index 6c84ea14..ebd961ce 100644
--- a/tests/trackingtest.py
+++ b/tests/trackingtest.py
@@ -87,14 +87,14 @@ def match_track(gen_track, expected_tracks):
         start_diff = abs(track.start - gen_track.start_s)
 
         gen_start = gen_track.bounds_history[0]
-        distance = tools.eucl_distance(
+        distance = tools.eucl_distance_sq(
             (track.start_pos.mid_x, track.start_pos.mid_y),
             (gen_start.mid_x, gen_start.mid_y),
         )
-        distance += tools.eucl_distance(
+        distance += tools.eucl_distance_sq(
             (track.start_pos.x, track.start_pos.y), (gen_start.x, gen_start.y)
         )
-        distance += tools.eucl_distance(
+        distance += tools.eucl_distance_sq(
             (track.start_pos.right, track.start_pos.bottom),
             (gen_start.right, gen_start.bottom),
         )