CamTrapTools 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- camtraptools/__init__.py +3 -0
- camtraptools/activity.py +255 -0
- camtraptools/adapt.py +453 -0
- camtraptools/check.py +232 -0
- camtraptools/cli.py +887 -0
- camtraptools/compare.py +189 -0
- camtraptools/crops.py +191 -0
- camtraptools/detect.py +319 -0
- camtraptools/ecology.py +372 -0
- camtraptools/evaluate.py +267 -0
- camtraptools/events.py +104 -0
- camtraptools/export.py +159 -0
- camtraptools/importer.py +116 -0
- camtraptools/ingest.py +376 -0
- camtraptools/labels.py +297 -0
- camtraptools/report.py +315 -0
- camtraptools/review.py +155 -0
- camtraptools/reviews.py +192 -0
- camtraptools/templates/evaluation.html +115 -0
- camtraptools/templates/event.html +148 -0
- camtraptools/templates/events.html +48 -0
- camtraptools-0.6.0.dist-info/METADATA +302 -0
- camtraptools-0.6.0.dist-info/RECORD +26 -0
- camtraptools-0.6.0.dist-info/WHEEL +4 -0
- camtraptools-0.6.0.dist-info/entry_points.txt +2 -0
- camtraptools-0.6.0.dist-info/licenses/LICENSE +21 -0
camtraptools/__init__.py
ADDED
camtraptools/activity.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Daily activity patterns and the overlap between species (Ridout & Linkie 2009).
|
|
2
|
+
|
|
3
|
+
Each independent event's start time is a point on the 24-hour circle. A species'
|
|
4
|
+
activity curve is a kernel density on that circle with von Mises kernels, whose
|
|
5
|
+
concentration follows the rule of Taylor (2008) as in the R package ``overlap``. The
|
|
6
|
+
overlap coefficient Δ is the area under the lower of two curves: 0 when two species
|
|
7
|
+
are never active at the same time of day, 1 when their patterns are identical.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from functools import cache
|
|
12
|
+
from itertools import combinations
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
ACTIVITY_FILE = "activity.csv"
|
|
19
|
+
OVERLAP_FILE = "overlap.csv"
|
|
20
|
+
PLOT_FILE = "activity.svg"
|
|
21
|
+
DEFAULT_MIN_EVENTS = 20
|
|
22
|
+
GRID_POINTS = 512
|
|
23
|
+
DIRECT_LIMIT = 2_000_000
|
|
24
|
+
BINS = 1440
|
|
25
|
+
MAX_MOMENT = 3
|
|
26
|
+
LARGE_SAMPLE = 75
|
|
27
|
+
BOOTSTRAP_SAMPLES = 999
|
|
28
|
+
PLOTTED_SPECIES = 6
|
|
29
|
+
COLOURS = ["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#E69F00", "#56B4E9"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def to_radians(times: pd.Series) -> np.ndarray:
|
|
33
|
+
"""Return clock times as angles on the 24-hour circle, in radians."""
|
|
34
|
+
stamps = pd.to_datetime(times, format="ISO8601")
|
|
35
|
+
hours = stamps.dt.hour + stamps.dt.minute / 60 + stamps.dt.second / 3600
|
|
36
|
+
return (hours.to_numpy(dtype=float) / 24) * 2 * math.pi
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def scaled_bessel(order: int, value):
|
|
40
|
+
"""Return e^(−value) · I_order(value), the modified Bessel function of the first
|
|
41
|
+
kind scaled to stay finite, from its integral form. ``value`` may be an array."""
|
|
42
|
+
theta = np.linspace(0, math.pi, 2001)
|
|
43
|
+
values = np.atleast_1d(np.asarray(value, dtype=float))
|
|
44
|
+
integrand = np.exp(values[:, None] * (np.cos(theta) - 1)) * np.cos(order * theta)
|
|
45
|
+
result = np.trapezoid(integrand, theta, axis=1) / math.pi
|
|
46
|
+
return result if np.ndim(value) else float(result[0])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
KAPPA_GRID = np.concatenate([np.linspace(0, 20, 2001), np.geomspace(20.05, 500, 400)])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cache
|
|
53
|
+
def moment_lengths(order: int) -> np.ndarray:
|
|
54
|
+
"""Return I_order(κ) / I_0(κ), the mean resultant length of the trigonometric
|
|
55
|
+
moment of ``order`` of a von Mises distribution, for every κ in ``KAPPA_GRID``."""
|
|
56
|
+
return scaled_bessel(order, KAPPA_GRID) / scaled_bessel(0, KAPPA_GRID)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def concentration_from_moment(order: int, length: float) -> float:
|
|
60
|
+
"""Return the von Mises concentration κ whose trigonometric moment of ``order`` has
|
|
61
|
+
mean resultant length ``length``, i.e. I_order(κ) / I_0(κ) = length."""
|
|
62
|
+
return float(np.interp(length, moment_lengths(order), KAPPA_GRID))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def von_mises_concentration(angles: np.ndarray) -> float:
|
|
66
|
+
"""Return the concentration κ of a von Mises distribution matching ``angles``,
|
|
67
|
+
estimated from each trigonometric moment up to order ``MAX_MOMENT`` and taking
|
|
68
|
+
the largest, so activity with two or three peaks is not mistaken for flat
|
|
69
|
+
activity (as in the R package ``overlap``)."""
|
|
70
|
+
estimates = []
|
|
71
|
+
for order in range(1, MAX_MOMENT + 1):
|
|
72
|
+
length = abs(np.exp(1j * order * angles).mean())
|
|
73
|
+
estimates.append(concentration_from_moment(order, length))
|
|
74
|
+
return max(estimates)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def bandwidth(angles: np.ndarray) -> float:
|
|
78
|
+
"""Return the concentration of the von Mises kernels for ``angles`` with the
|
|
79
|
+
plug-in rule of Taylor (2008): larger for bigger samples and sharper activity, so
|
|
80
|
+
the curve follows the data more closely."""
|
|
81
|
+
kappa = von_mises_concentration(angles)
|
|
82
|
+
if kappa == 0:
|
|
83
|
+
return 0.0
|
|
84
|
+
# I_2(2κ) / I_0(κ)² is the same for the scaled functions, as e^(2κ) cancels
|
|
85
|
+
ratio = scaled_bessel(2, 2 * kappa) / scaled_bessel(0, kappa) ** 2
|
|
86
|
+
return (3 * len(angles) * kappa**2 * ratio / (4 * math.sqrt(math.pi))) ** (2 / 5)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def density(angles: np.ndarray, at: np.ndarray, concentration: float) -> np.ndarray:
|
|
90
|
+
"""Return the circular kernel density of ``angles`` at the angles ``at``, per
|
|
91
|
+
radian, so that it integrates to 1 over the day.
|
|
92
|
+
|
|
93
|
+
Small samples are computed exactly. For large ones (more than
|
|
94
|
+
``DIRECT_LIMIT`` kernel evaluations), times are counted in one-minute bins, the
|
|
95
|
+
kernel is applied to the bins with a fast Fourier transform, and the result is
|
|
96
|
+
interpolated, which is far faster and differs negligibly.
|
|
97
|
+
"""
|
|
98
|
+
normalizer = 2 * math.pi * scaled_bessel(0, concentration)
|
|
99
|
+
if len(angles) * len(at) <= DIRECT_LIMIT:
|
|
100
|
+
differences = np.cos(at[:, None] - angles[None, :]) - 1
|
|
101
|
+
return (np.exp(concentration * differences) / normalizer).mean(axis=1)
|
|
102
|
+
step = 2 * math.pi / BINS
|
|
103
|
+
counts = np.bincount(np.floor(np.mod(angles, 2 * math.pi) / step).astype(int) % BINS, minlength=BINS)
|
|
104
|
+
centres = (np.arange(BINS) + 0.5) * step
|
|
105
|
+
kernel = np.exp(concentration * (np.cos(np.arange(BINS) * step) - 1)) / normalizer
|
|
106
|
+
smoothed = np.fft.irfft(np.fft.rfft(counts / len(angles)) * np.fft.rfft(kernel), n=BINS)
|
|
107
|
+
return np.interp(np.mod(at, 2 * math.pi), centres, smoothed, period=2 * math.pi)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def grid() -> np.ndarray:
|
|
111
|
+
"""Return evenly spaced angles around the day."""
|
|
112
|
+
return np.linspace(0, 2 * math.pi, GRID_POINTS, endpoint=False)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def overlap(first: np.ndarray, second: np.ndarray) -> float:
|
|
116
|
+
"""Return the overlap coefficient of two samples of times: Δ₄ when the smaller has
|
|
117
|
+
at least ``LARGE_SAMPLE`` events, otherwise Δ₁, as Ridout & Linkie recommend."""
|
|
118
|
+
if min(len(first), len(second)) >= LARGE_SAMPLE:
|
|
119
|
+
f_first = density(first, first, bandwidth(first))
|
|
120
|
+
g_first = density(second, first, bandwidth(second))
|
|
121
|
+
f_second = density(first, second, bandwidth(first))
|
|
122
|
+
g_second = density(second, second, bandwidth(second))
|
|
123
|
+
return 0.5 * (np.minimum(1, g_first / f_first).mean() + np.minimum(1, f_second / g_second).mean())
|
|
124
|
+
points = grid()
|
|
125
|
+
f = density(first, points, bandwidth(first) / 0.8)
|
|
126
|
+
g = density(second, points, bandwidth(second) / 0.8)
|
|
127
|
+
return float(np.minimum(f, g).mean() * 2 * math.pi)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def overlap_interval(
|
|
131
|
+
first: np.ndarray, second: np.ndarray, samples: int = BOOTSTRAP_SAMPLES, seed: int = 0
|
|
132
|
+
) -> tuple[float, float, float]:
|
|
133
|
+
"""Return the overlap coefficient with a 95% percentile bootstrap interval,
|
|
134
|
+
resampling each species' events with replacement. In simulations, percentile
|
|
135
|
+
intervals held their 95% coverage where basic intervals fell to about 80%."""
|
|
136
|
+
estimate = overlap(first, second)
|
|
137
|
+
rng = np.random.default_rng(seed)
|
|
138
|
+
replicates = np.array(
|
|
139
|
+
[overlap(rng.choice(first, len(first)), rng.choice(second, len(second))) for _ in range(samples)]
|
|
140
|
+
)
|
|
141
|
+
low, high = np.quantile(replicates, [0.025, 0.975])
|
|
142
|
+
return estimate, float(low), float(high)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def activity_curves(times: dict[str, np.ndarray]) -> pd.DataFrame:
|
|
146
|
+
"""Return each species' activity curve on a grid of times of day, as the share of
|
|
147
|
+
its daily activity per hour."""
|
|
148
|
+
points = grid()
|
|
149
|
+
curves = pd.DataFrame({"hour": np.round(points / (2 * math.pi) * 24, 4)})
|
|
150
|
+
for name, angles in times.items():
|
|
151
|
+
curves[name] = density(angles, points, bandwidth(angles)) * 2 * math.pi / 24
|
|
152
|
+
return curves
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def peak_hour(curve: pd.Series, hours: pd.Series) -> str:
|
|
156
|
+
"""Return the time of day at which an activity curve is highest, as HH:MM."""
|
|
157
|
+
hour = float(hours[curve.idxmax()])
|
|
158
|
+
return f"{int(hour):02d}:{round(hour % 1 * 60) % 60:02d}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def overlaps(times: dict[str, np.ndarray], samples: int = BOOTSTRAP_SAMPLES) -> pd.DataFrame:
|
|
162
|
+
"""Return the overlap coefficient and its interval for every pair of species."""
|
|
163
|
+
rows = []
|
|
164
|
+
for first, second in combinations(times, 2):
|
|
165
|
+
estimate, low, high = overlap_interval(times[first], times[second], samples)
|
|
166
|
+
rows.append(
|
|
167
|
+
{
|
|
168
|
+
"first": first,
|
|
169
|
+
"second": second,
|
|
170
|
+
"estimator": "Δ4" if min(len(times[first]), len(times[second])) >= LARGE_SAMPLE else "Δ1",
|
|
171
|
+
"overlap": round(estimate, 2),
|
|
172
|
+
"overlapLower": round(low, 2),
|
|
173
|
+
"overlapUpper": round(high, 2),
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
columns = ["first", "second", "estimator", "overlap", "overlapLower", "overlapUpper"]
|
|
177
|
+
return pd.DataFrame(rows, columns=columns).sort_values("overlap", ascending=False, ignore_index=True)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def plot(curves: pd.DataFrame, counts: dict[str, int]) -> str:
|
|
181
|
+
"""Return an SVG line chart of the activity curves of the most recorded species."""
|
|
182
|
+
width, height, left, right, top, bottom = 720, 360, 56, 200, 20, 44
|
|
183
|
+
plotted = sorted(counts, key=counts.get, reverse=True)[:PLOTTED_SPECIES]
|
|
184
|
+
peak = max(curves[name].max() for name in plotted) * 1.1
|
|
185
|
+
inner_width, inner_height = width - left - right, height - top - bottom
|
|
186
|
+
|
|
187
|
+
def x(hour: float) -> float:
|
|
188
|
+
return left + hour / 24 * inner_width
|
|
189
|
+
|
|
190
|
+
def y(value: float) -> float:
|
|
191
|
+
return top + inner_height * (1 - value / peak)
|
|
192
|
+
|
|
193
|
+
parts = [
|
|
194
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" font-family="sans-serif" font-size="12">',
|
|
195
|
+
f'<rect width="{width}" height="{height}" fill="white"/>',
|
|
196
|
+
]
|
|
197
|
+
step = next((size for size in [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0] if peak / size <= 6), peak / 4)
|
|
198
|
+
for tick in np.arange(step, peak, step):
|
|
199
|
+
parts.append(
|
|
200
|
+
f'<line x1="{left}" y1="{y(tick):.1f}" x2="{left + inner_width}" y2="{y(tick):.1f}" stroke="#f0f0f0"/>'
|
|
201
|
+
)
|
|
202
|
+
parts.append(f'<text x="{left - 6}" y="{y(tick) + 4:.1f}" text-anchor="end">{tick:.0%}</text>')
|
|
203
|
+
for hour in range(0, 25, 3):
|
|
204
|
+
parts.append(
|
|
205
|
+
f'<line x1="{x(hour):.1f}" y1="{top}" x2="{x(hour):.1f}" y2="{top + inner_height}" stroke="#e5e5e5"/>'
|
|
206
|
+
)
|
|
207
|
+
parts.append(f'<text x="{x(hour):.1f}" y="{height - 24}" text-anchor="middle">{hour:02d}:00</text>')
|
|
208
|
+
parts.append(
|
|
209
|
+
f'<line x1="{left}" y1="{top + inner_height}" x2="{left + inner_width}" y2="{top + inner_height}" '
|
|
210
|
+
'stroke="#444"/>'
|
|
211
|
+
)
|
|
212
|
+
parts.append(f'<text x="{left + inner_width / 2}" y="{height - 6}" text-anchor="middle">Time of day</text>')
|
|
213
|
+
parts.append(
|
|
214
|
+
f'<text transform="translate(14 {top + inner_height / 2}) rotate(-90)" text-anchor="middle">'
|
|
215
|
+
"Share of activity per hour</text>"
|
|
216
|
+
)
|
|
217
|
+
for index, name in enumerate(plotted):
|
|
218
|
+
colour = COLOURS[index % len(COLOURS)]
|
|
219
|
+
points = " ".join(f"{x(hour):.1f},{y(value):.1f}" for hour, value in zip(curves["hour"], curves[name]))
|
|
220
|
+
points += f" {x(24):.1f},{y(curves[name].iloc[0]):.1f}"
|
|
221
|
+
parts.append(f'<polyline points="{points}" fill="none" stroke="{colour}" stroke-width="2"/>')
|
|
222
|
+
legend_y = top + 16 + index * 22
|
|
223
|
+
parts.append(
|
|
224
|
+
f'<line x1="{width - right + 16}" y1="{legend_y}" x2="{width - right + 40}" y2="{legend_y}" '
|
|
225
|
+
f'stroke="{colour}" stroke-width="3"/>'
|
|
226
|
+
)
|
|
227
|
+
label = f"{name} ({counts[name]})".replace("&", "&").replace("<", "<")
|
|
228
|
+
parts.append(f'<text x="{width - right + 46}" y="{legend_y + 4}" font-style="italic">{label}</text>')
|
|
229
|
+
parts.append("</svg>")
|
|
230
|
+
return "\n".join(parts)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run(events: pd.DataFrame, out_dir: Path, min_events: int = DEFAULT_MIN_EVENTS) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
234
|
+
"""Write activity curves (``activity.csv``), pairwise overlaps (``overlap.csv``) and
|
|
235
|
+
a chart (``activity.svg``) for every species with at least ``min_events`` events.
|
|
236
|
+
|
|
237
|
+
``events`` has an ``eventStart`` and a ``scientificName`` column. Returns a table of
|
|
238
|
+
events and peak hour per species, and the overlaps.
|
|
239
|
+
"""
|
|
240
|
+
counts = events["scientificName"].value_counts()
|
|
241
|
+
chosen = [name for name, count in counts.items() if count >= min_events]
|
|
242
|
+
times = {name: to_radians(events.loc[events["scientificName"] == name, "eventStart"]) for name in chosen}
|
|
243
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
if not times:
|
|
245
|
+
return pd.DataFrame(columns=["events", "peak"]), overlaps({})
|
|
246
|
+
curves = activity_curves(times)
|
|
247
|
+
curves.to_csv(out_dir / ACTIVITY_FILE, index=False)
|
|
248
|
+
pairs = overlaps(times)
|
|
249
|
+
pairs.to_csv(out_dir / OVERLAP_FILE, index=False)
|
|
250
|
+
(out_dir / PLOT_FILE).write_text(plot(curves, {name: len(times[name]) for name in times}), encoding="utf-8")
|
|
251
|
+
peaks = pd.DataFrame(
|
|
252
|
+
{"events": [len(times[name]) for name in times], "peak": [peak_hour(curves[name], curves["hour"]) for name in times]},
|
|
253
|
+
index=pd.Index(list(times), name="taxon"),
|
|
254
|
+
)
|
|
255
|
+
return peaks, pairs
|