explore view with new api endpoint

This commit is contained in:
Josh Hawkins 2024-09-11 06:42:49 -05:00
parent 52c8386b52
commit e5e074ab84
6 changed files with 218 additions and 373 deletions

View File

@ -251,6 +251,61 @@ def events():
return jsonify(list(events))
@EventBp.route("/events/explore")
def events_explore():
limit = request.args.get("limit", 10, type=int)
subquery = Event.select(
Event.id,
Event.camera,
Event.label,
Event.zones,
Event.start_time,
Event.end_time,
Event.has_clip,
Event.has_snapshot,
Event.plus_id,
Event.retain_indefinitely,
Event.sub_label,
Event.top_score,
Event.false_positive,
Event.box,
Event.data,
fn.rank()
.over(partition_by=[Event.label], order_by=[Event.start_time.desc()])
.alias("rank"),
fn.COUNT(Event.id).over(partition_by=[Event.label]).alias("event_count"),
).alias("subquery")
query = (
Event.select(
subquery.c.id,
subquery.c.camera,
subquery.c.label,
subquery.c.zones,
subquery.c.start_time,
subquery.c.end_time,
subquery.c.has_clip,
subquery.c.has_snapshot,
subquery.c.plus_id,
subquery.c.retain_indefinitely,
subquery.c.sub_label,
subquery.c.top_score,
subquery.c.false_positive,
subquery.c.box,
subquery.c.data,
subquery.c.event_count,
)
.from_(subquery)
.where(subquery.c.rank <= limit)
.order_by(subquery.c.event_count.desc(), subquery.c.start_time.desc())
.dicts()
)
events = query.iterator()
return jsonify(list(events))
@EventBp.route("/event_ids")
def event_ids():
idString = request.args.get("ids")

View File

@ -179,15 +179,6 @@ export default function SearchFilterGroup({
updateSelectedRange={onUpdateSelectedRange}
/>
)}
{filters.includes("general") && (
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
/>
)}
{filters.includes("zone") && allZones.length > 0 && (
<ZoneFilterButton
allZones={filterValues.zones}
@ -197,6 +188,15 @@ export default function SearchFilterGroup({
}
/>
)}
{filters.includes("general") && (
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
/>
)}
{filters.includes("sub") && (
<SubFilterButton
allSubLabels={allSubLabels}
@ -247,7 +247,7 @@ function GeneralFilterButton({
<div
className={`hidden md:block ${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`}
>
Filter
All Labels
</div>
</Button>
);

View File

@ -11,7 +11,6 @@ import {
import { TimeRange } from "@/types/timeline";
import { RecordingView } from "@/views/recording/RecordingView";
import SearchView from "@/views/search/SearchView";
import ExploreView from "@/views/explore/ExploreView";
import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
@ -119,21 +118,25 @@ export default function Explore() {
];
}
return [
"events",
{
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"],
limit: Object.keys(searchSearchParams).length == 0 ? 20 : null,
in_progress: 0,
include_thumbnails: 0,
},
];
if (searchSearchParams && Object.keys(searchSearchParams).length !== 0) {
return [
"events",
{
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"],
limit: Object.keys(searchSearchParams).length == 0 ? 20 : null,
in_progress: 0,
include_thumbnails: 0,
},
];
}
return null;
}, [searchTerm, searchSearchParams, similaritySearch]);
const { data: searchResults, isLoading } =
@ -225,31 +228,19 @@ export default function Explore() {
);
}
} else {
if (
search ||
similaritySearch ||
(searchFilter && Object.keys(searchFilter).length != 0)
) {
return (
<SearchView
search={search}
searchTerm={searchTerm}
searchFilter={searchFilter}
searchResults={searchResults}
isLoading={isLoading}
setSearch={setSearch}
similaritySearch={similaritySearch}
setSimilaritySearch={setSimilaritySearch}
onUpdateFilter={onUpdateFilter}
onOpenSearch={onOpenSearch}
/>
);
} else {
return (
<div className="flex size-full flex-col pt-2 md:py-2">
<ExploreView />
</div>
);
}
return (
<SearchView
search={search}
searchTerm={searchTerm}
searchFilter={searchFilter}
searchResults={searchResults}
isLoading={isLoading}
setSearch={setSearch}
similaritySearch={similaritySearch}
setSimilaritySearch={setSimilaritySearch}
onUpdateFilter={onUpdateFilter}
onOpenSearch={onOpenSearch}
/>
);
}
}

View File

@ -1,242 +0,0 @@
import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { useOverlayState } from "@/hooks/use-overlay-state";
import { FrigateConfig } from "@/types/frigateConfig";
import { RecordingStartingPoint } from "@/types/record";
import {
PartialSearchResult,
SearchFilter,
SearchResult,
} from "@/types/search";
import { TimeRange } from "@/types/timeline";
import { RecordingView } from "@/views/recording/RecordingView";
import SearchView from "@/views/search/SearchView";
import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
export default function Search() {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
// search field handler
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();
const [search, setSearch] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [recording, setRecording] =
useOverlayState<RecordingStartingPoint>("recording");
// search filter
const [searchFilter, setSearchFilter, searchSearchParams] =
useApiFilterArgs<SearchFilter>();
const onUpdateFilter = useCallback(
(newFilter: SearchFilter) => {
setSearchFilter(newFilter);
},
[setSearchFilter],
);
// search api
const [similaritySearch, setSimilaritySearch] =
useState<PartialSearchResult>();
useEffect(() => {
if (
config?.semantic_search.enabled &&
searchSearchParams["search_type"] == "similarity" &&
searchSearchParams["event_id"]?.length != 0 &&
searchFilter
) {
setSimilaritySearch({
id: searchSearchParams["event_id"],
});
// remove event id from url params
const { event_id: _event_id, ...newFilter } = searchFilter;
setSearchFilter(newFilter);
}
// only run similarity search with event_id in the url when coming from review
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (similaritySearch) {
setSimilaritySearch(undefined);
}
if (searchTimeout) {
clearTimeout(searchTimeout);
}
setSearchTimeout(
setTimeout(() => {
setSearchTimeout(undefined);
setSearchTerm(search);
}, 750),
);
// we only want to update the searchTerm when search changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
const searchQuery = useMemo(() => {
if (similaritySearch) {
return [
"events/search",
{
query: similaritySearch.id,
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
include_thumbnails: 0,
search_type: "similarity",
},
];
}
if (searchTerm) {
return [
"events/search",
{
query: searchTerm,
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"],
include_thumbnails: 0,
},
];
}
return [
"events",
{
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"],
limit: Object.keys(searchSearchParams).length == 0 ? 20 : null,
in_progress: 0,
include_thumbnails: 0,
},
];
}, [searchTerm, searchSearchParams, similaritySearch]);
const { data: searchResults, isLoading } =
useSWR<SearchResult[]>(searchQuery);
const previewTimeRange = useMemo<TimeRange>(() => {
if (!searchResults) {
return { after: 0, before: 0 };
}
return {
after: Math.min(...searchResults.map((res) => res.start_time)),
before: Math.max(
...searchResults.map((res) => res.end_time ?? Date.now() / 1000),
),
};
}, [searchResults]);
const allPreviews = useCameraPreviews(previewTimeRange, {
autoRefresh: false,
fetchPreviews: searchResults != undefined,
});
// selection
const onOpenSearch = useCallback(
(item: SearchResult) => {
setRecording({
camera: item.camera,
startTime: item.start_time,
severity: "alert",
});
},
[setRecording],
);
const selectedReviewData = useMemo(() => {
if (!recording) {
return undefined;
}
if (!config) {
return undefined;
}
if (!searchResults) {
return undefined;
}
const allCameras = searchFilter?.cameras ?? Object.keys(config.cameras);
return {
camera: recording.camera,
start_time: recording.startTime,
allCameras: allCameras,
};
// previews will not update after item is selected
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recording, searchResults]);
const selectedTimeRange = useMemo(() => {
if (!recording) {
return undefined;
}
const time = new Date(recording.startTime * 1000);
time.setUTCMinutes(0, 0, 0);
const start = time.getTime() / 1000;
time.setHours(time.getHours() + 2);
const end = time.getTime() / 1000;
return {
after: start,
before: end,
};
}, [recording]);
if (recording) {
if (selectedReviewData && selectedTimeRange) {
return (
<RecordingView
startCamera={selectedReviewData.camera}
startTime={selectedReviewData.start_time}
allCameras={selectedReviewData.allCameras}
allPreviews={allPreviews}
timeRange={selectedTimeRange}
updateFilter={onUpdateFilter}
/>
);
}
} else {
return (
<SearchView
search={search}
searchTerm={searchTerm}
searchFilter={searchFilter}
searchResults={searchResults}
isLoading={isLoading}
setSearch={setSearch}
similaritySearch={similaritySearch}
setSimilaritySearch={setSimilaritySearch}
onUpdateFilter={onUpdateFilter}
onOpenSearch={onOpenSearch}
/>
);
}
}

View File

@ -1,6 +1,5 @@
import { Event } from "@/types/event";
import { useEffect, useMemo, useState } from "react";
import { isIOS } from "react-device-detect";
import { isIOS, isMobileOnly } from "react-device-detect";
import useSWR from "swr";
import { useApiHost } from "@/api";
import { cn } from "@/lib/utils";
@ -12,8 +11,13 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { SearchResult } from "@/types/search";
export default function ImageAccordion() {
type ExploreViewProps = {
onSelectSearch: (searchResult: SearchResult, detail: boolean) => void;
};
export default function ExploreView({ onSelectSearch }: ExploreViewProps) {
// title
useEffect(() => {
@ -22,11 +26,11 @@ export default function ImageAccordion() {
// data
const { data: events } = useSWR<Event[]>(
const { data: events } = useSWR<SearchResult[]>(
[
"events",
"events/explore",
{
limit: 100,
limit: isMobileOnly ? 5 : 10,
},
],
{
@ -36,7 +40,7 @@ export default function ImageAccordion() {
const eventsByLabel = useMemo(() => {
if (!events) return {};
return events.reduce<Record<string, Event[]>>((acc, event) => {
return events.reduce<Record<string, SearchResult[]>>((acc, event) => {
const label = event.label || "Unknown";
if (!acc[label]) {
acc[label] = [];
@ -49,19 +53,28 @@ export default function ImageAccordion() {
return (
<div className="space-y-4 overflow-x-hidden p-2">
{Object.entries(eventsByLabel).map(([label, filteredEvents]) => (
<ThumbnailRow key={label} events={filteredEvents} objectType={label} />
<ThumbnailRow
key={label}
searchResults={filteredEvents}
objectType={label}
onSelectSearch={onSelectSearch}
/>
))}
</div>
);
}
type ThumbnailRowType = {
objectType: string;
searchResults?: SearchResult[];
onSelectSearch: (searchResult: SearchResult, detail: boolean) => void;
};
function ThumbnailRow({
objectType,
events,
}: {
objectType: string;
events?: Event[];
}) {
searchResults,
onSelectSearch,
}: ThumbnailRowType) {
const apiHost = useApiHost();
const navigate = useNavigate();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
@ -74,20 +87,34 @@ function ThumbnailRow({
};
return (
<div className="space-y-2">
<h2 className="text-lg capitalize">{objectType}</h2>
<div className="flex flex-row items-center space-x-2 p-2">
{events?.map((event, index) => (
<div className="rounded-lg bg-background_alt p-2 md:p-4">
<div className="text-lg capitalize">
{objectType.replaceAll("_", " ")}
{searchResults && (
<span className="ml-3 text-sm text-secondary-foreground">
(
{
// @ts-expect-error we know this is correct
searchResults[0].event_count
}{" "}
detected objects){" "}
</span>
)}
</div>
<div className="flex flex-row items-center space-x-2 py-2">
{searchResults?.map((event, index) => (
<div
key={event.id}
className="relative aspect-square h-[50px] w-full max-w-[50px] md:h-[120px] md:max-w-[120px]"
className="relative aspect-square h-auto max-w-[20%] flex-grow md:max-w-[10%]"
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
<img
className={cn(
"absolute h-full w-full rounded-lg object-cover transition-all duration-300 ease-in-out md:rounded-2xl",
hoveredIndex === index ? "z-10 scale-110" : "scale-100",
hoveredIndex === index
? "z-10 scale-110 cursor-pointer"
: "scale-100",
)}
style={
isIOS
@ -98,12 +125,9 @@ function ThumbnailRow({
: undefined
}
draggable={false}
src={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
}
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
alt={`${objectType} snapshot`}
onClick={() => onSelectSearch(event, true)}
/>
</div>
))}
@ -114,13 +138,13 @@ function ThumbnailRow({
<Tooltip>
<TooltipTrigger>
<LuArrowRightCircle
className="text-secondary-foreground"
className="ml-2 text-secondary-foreground transition-all duration-300 hover:text-primary"
size={24}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="capitalize">
Explore More {objectType}s
<ExploreMoreLink objectType={objectType} />
</TooltipContent>
</TooltipPortal>
</Tooltip>
@ -129,3 +153,12 @@ function ThumbnailRow({
</div>
);
}
function ExploreMoreLink({ objectType }: { objectType: string }) {
const formattedType = objectType.replaceAll("_", " ");
const label = formattedType.endsWith("s")
? `${formattedType}es`
: `${formattedType}s`;
return <div>Explore More {label}</div>;
}

View File

@ -21,6 +21,7 @@ import { useCallback, useMemo, useState } from "react";
import { isMobileOnly } from "react-device-detect";
import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu";
import useSWR from "swr";
import ExploreView from "../explore/ExploreView";
type SearchViewProps = {
search: string;
@ -109,7 +110,7 @@ export default function SearchView({
<div
className={cn(
"relative mb-2 flex h-11 items-center pl-2 pr-2 md:pl-3",
"relative flex h-11 items-center pl-2 pr-2 md:pl-3",
config?.semantic_search?.enabled
? "justify-between"
: "justify-center",
@ -120,7 +121,7 @@ export default function SearchView({
<div
className={cn(
"relative w-full",
hasExistingSearch ? "mr-3 md:w-1/3" : "md:ml-[25%] md:w-1/2",
hasExistingSearch ? "md:mr-3 md:w-1/3" : "md:ml-[25%] md:w-1/2",
)}
>
<Input
@ -161,66 +162,73 @@ export default function SearchView({
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
<div className="grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6">
{uniqueResults &&
uniqueResults.map((value) => {
const selected = false;
{uniqueResults && (
<div className="mt-2 grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6">
{uniqueResults &&
uniqueResults.map((value) => {
const selected = false;
return (
<div
key={value.id}
data-start={value.start_time}
className="review-item relative rounded-lg"
>
return (
<div
className={cn(
"aspect-square size-full overflow-hidden rounded-lg",
)}
key={value.id}
data-start={value.start_time}
className="review-item relative rounded-lg"
>
<SearchThumbnail
searchResult={value}
scrollLock={false}
findSimilar={() => setSimilaritySearch(value)}
onClick={onSelectSearch}
/>
{(searchTerm || similaritySearch) && (
<div className={cn("absolute right-2 top-2 z-40")}>
<Tooltip>
<TooltipTrigger>
<Chip
className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize text-white`}
>
{value.search_source == "thumbnail" ? (
<LuImage className="mr-1 size-3" />
) : (
<LuText className="mr-1 size-3" />
)}
<div
className={cn(
"aspect-square size-full overflow-hidden rounded-lg",
)}
>
<SearchThumbnail
searchResult={value}
scrollLock={false}
findSimilar={() => setSimilaritySearch(value)}
onClick={onSelectSearch}
/>
{(searchTerm || similaritySearch) && (
<div className={cn("absolute right-2 top-2 z-40")}>
<Tooltip>
<TooltipTrigger>
<Chip
className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize text-white`}
>
{value.search_source == "thumbnail" ? (
<LuImage className="mr-1 size-3" />
) : (
<LuText className="mr-1 size-3" />
)}
{zScoreToConfidence(
value.search_distance,
value.search_source,
)}
%
</Chip>
</TooltipTrigger>
<TooltipContent>
Matched {value.search_source} at{" "}
{zScoreToConfidence(
value.search_distance,
value.search_source,
)}
%
</Chip>
</TooltipTrigger>
<TooltipContent>
Matched {value.search_source} at{" "}
{zScoreToConfidence(
value.search_distance,
value.search_source,
)}
%
</TooltipContent>
</Tooltip>
</div>
)}
</TooltipContent>
</Tooltip>
</div>
)}
</div>
<div
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`}
/>
</div>
<div
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`}
/>
</div>
);
})}
</div>
);
})}
</div>
)}
{!uniqueResults && !isLoading && (
<div className="flex size-full flex-col">
<ExploreView onSelectSearch={onSelectSearch} />
</div>
)}
</div>
</div>
);