Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,30 @@ def sync(self, _request, pk=None) -> Response:
else:
raise api_exceptions.ValidationError(detail="Deployment must have a data source to sync captures from")

@action(detail=True, methods=["post"], name="regroup-sessions", url_path="regroup-sessions")
def regroup_sessions(self, _request, pk=None) -> Response:
"""
Queue a background task to regroup the deployment's source images into sessions.

Comment thread
mihow marked this conversation as resolved.
Uses the project's ``session_time_gap_seconds`` setting to determine
the maximum gap between consecutive images before a new session is started.

(Sessions are stored as ``Event`` records internally.)
"""
from ami.tasks import regroup_events as regroup_events_task

deployment: Deployment = self.get_object()
async_result = regroup_events_task.delay(deployment.pk)
logger.info(f"Queued regroup_sessions for deployment {deployment.pk} (task {async_result.id})")
return Response(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
"task_id": async_result.id,
"deployment_id": deployment.pk,
"project_id": deployment.project_id,
},
status=status.HTTP_202_ACCEPTED,
)

@extend_schema(parameters=[project_id_doc_param])
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
Expand Down
7 changes: 6 additions & 1 deletion ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1394,10 +1394,15 @@ def audit_event_lengths(deployment: Deployment):

def group_images_into_events(
deployment: Deployment,
max_time_gap=datetime.timedelta(minutes=120),
max_time_gap: datetime.timedelta | None = None,
delete_empty=True,
max_event_duration: datetime.timedelta | None = DEFAULT_MAX_EVENT_DURATION,
) -> list[Event]:
if max_time_gap is None:
if deployment.project_id:
max_time_gap = datetime.timedelta(seconds=deployment.project.session_time_gap_seconds)
else:
max_time_gap = datetime.timedelta(minutes=120)
Comment thread
mihow marked this conversation as resolved.
Outdated
# Log a warning if multiple SourceImages have the same timestamp
dupes = (
SourceImage.objects.filter(deployment=deployment)
Expand Down
91 changes: 91 additions & 0 deletions ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,97 @@ def test_regrouping_realigns_occurrence_event_id(self):
# No occurrence should be left pointing at a deleted/missing event.
assert Occurrence.objects.filter(deployment=self.deployment, event__isnull=True).count() == 0

def _create_burst(self, start: datetime.datetime, n: int, interval_minutes: int = 5):
"""Create ``n`` captures spaced ``interval_minutes`` apart starting at ``start``."""
import pathlib
import uuid

for i in range(n):
SourceImage.objects.create(
deployment=self.deployment,
timestamp=start + datetime.timedelta(minutes=i * interval_minutes),
path=pathlib.Path("test") / f"{uuid.uuid4().hex[:8]}_burst_{i}.jpg",
)

def test_cross_midnight_bursts_split_by_short_gap(self):
"""
User-reported pattern: a "night" with a multi-hour off-window between
bursts that crosses midnight gets split into two separate Events at
the default 2 h gap, because the second burst's start_date is on the
next calendar day. Bumping ``max_time_gap`` past the off-window
merges both bursts into a single timestamp_group whose start_date
determines the group_by date, yielding one Event.
"""
# Burst A: 22:00–22:25 on day N
self._create_burst(datetime.datetime(2023, 8, 5, 22, 0), n=6, interval_minutes=5)
# Burst B: 01:00–01:25 on day N+1 (off-window = 2 h 35 min, crosses midnight)
self._create_burst(datetime.datetime(2023, 8, 6, 1, 0), n=6, interval_minutes=5)

# Default 2 h gap → splits into two Events on different dates.
events_default = group_images_into_events(
deployment=self.deployment,
max_time_gap=datetime.timedelta(hours=2),
)
assert len(events_default) == 2, f"expected 2 Events at 2h gap, got {len(events_default)}"

# 4 h gap (off-window 2h35m < 4h) → single timestamp_group → one Event.
events_widened = group_images_into_events(
deployment=self.deployment,
max_time_gap=datetime.timedelta(hours=4),
)
assert len(events_widened) == 1, f"expected 1 Event at 4h gap, got {len(events_widened)}"

def test_same_date_bursts_merge_regardless_of_gap(self):
"""
Inverse of the above: bursts that DON'T cross midnight collide on
``group_by = start_date.date()`` even when the gap setting would
split them into separate timestamp_groups. This is the #904 caveat
— sub-day session-splits are masked by date-keyed Event reuse.
Documenting current behavior so a future fix (e.g. noon-to-noon
keying) has a regression target.
"""
# Two bursts on the SAME calendar date with a 1 h 35 min off-window between.
self._create_burst(datetime.datetime(2023, 8, 5, 20, 0), n=6, interval_minutes=5)
self._create_burst(datetime.datetime(2023, 8, 5, 22, 0), n=6, interval_minutes=5)

# 1 h gap → splits into two timestamp_groups, but both start_date.date()
# is 2023-08-05 → get_or_create collides → ONE Event.
# group_images_into_events returns one entry per timestamp_group (with
# duplicates when groups reuse the same Event), so assert on the DB count.
group_images_into_events(
deployment=self.deployment,
max_time_gap=datetime.timedelta(hours=1),
)
db_event_count = Event.objects.filter(deployment=self.deployment).count()
assert db_event_count == 1, f"expected 1 Event due to date collision, got {db_event_count}"

def test_session_time_gap_seconds_is_used_when_no_explicit_gap(self):
"""
When ``max_time_gap`` is not passed, ``group_images_into_events``
falls back to ``deployment.project.session_time_gap_seconds``. Verify
the project setting actually drives the split decision.
"""
# Same cross-midnight burst pattern as the first test.
self._create_burst(datetime.datetime(2023, 8, 5, 22, 0), n=6, interval_minutes=5)
self._create_burst(datetime.datetime(2023, 8, 6, 1, 0), n=6, interval_minutes=5)

# Project setting at 2 h (= 7200 s) → splits across midnight.
self.project.session_time_gap_seconds = 2 * 60 * 60
self.project.save()
# Bust the cached deployment.project relation so the function reads the new value.
self.deployment.refresh_from_db()
group_images_into_events(deployment=self.deployment)
count_2h = Event.objects.filter(deployment=self.deployment).count()
assert count_2h == 2, f"expected 2 Events at project setting 7200s, got {count_2h}"

# Project setting at 4 h (= 14400 s) → off-window 2h35m fits → single Event.
self.project.session_time_gap_seconds = 4 * 60 * 60
self.project.save()
self.deployment.refresh_from_db()
group_images_into_events(deployment=self.deployment)
count_4h = Event.objects.filter(deployment=self.deployment).count()
assert count_4h == 1, f"expected 1 Event at project setting 14400s, got {count_4h}"

def test_pruning_empty_events(self):
from ami.main.models import delete_empty_events

Expand Down
17 changes: 17 additions & 0 deletions ui/src/pages/project-details/processing-form.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FormController } from 'components/form/form-controller'
import { FormField } from 'components/form/form-field'
import {
FormActions,
FormError,
Expand All @@ -17,6 +18,7 @@ import { PipelinesSelect } from './pipelines-select'

interface ProcessingFormValues {
defaultProcessingPipeline: { id: string; name: string }
sessionTimeGapSeconds: number
}

const config: FormConfig = {
Expand All @@ -25,6 +27,12 @@ const config: FormConfig = {
description:
'The default pipeline to use for processing images in this project.',
},
sessionTimeGapSeconds: {
label: 'Maximum time gap between sessions (default)',
description:
'Maximum time gap (in seconds) between consecutive images before a new session is started. Default is 7200 seconds (2 hours).',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be nice if this was presented to the user as minutes (or hours) rather than seconds.

rules: { required: true, min: 1 },
},
Comment thread
mihow marked this conversation as resolved.
}

export const ProcessingForm = ({
Expand All @@ -47,6 +55,7 @@ export const ProcessingForm = ({
} = useForm<ProcessingFormValues>({
defaultValues: {
defaultProcessingPipeline: project.settings.defaultProcessingPipeline,
sessionTimeGapSeconds: project.settings.sessionTimeGapSeconds,
},
mode: 'onChange',
})
Expand Down Expand Up @@ -86,6 +95,14 @@ export const ProcessingForm = ({
)}
/>
</FormRow>
<FormRow>
<FormField
name="sessionTimeGapSeconds"
type="number"
config={config}
control={control}
/>
Comment thread
mihow marked this conversation as resolved.
</FormRow>
</FormSection>
<FormActions>
<Button size="small" type="submit" variant="success">
Expand Down
Loading