mcdonald 0.2.4__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.
Files changed (58) hide show
  1. mcdonald/__init__.py +33 -0
  2. mcdonald/actions.py +266 -0
  3. mcdonald/autolink.py +701 -0
  4. mcdonald/case_cli.py +56 -0
  5. mcdonald/catalog.py +200 -0
  6. mcdonald/cli.py +112 -0
  7. mcdonald/clip.py +345 -0
  8. mcdonald/comotion.py +317 -0
  9. mcdonald/docs/README-technical.md +441 -0
  10. mcdonald/docs/agents.md +406 -0
  11. mcdonald/docs/install.md +112 -0
  12. mcdonald/docs/method.md +313 -0
  13. mcdonald/figures.py +360 -0
  14. mcdonald/find_qt.py +325 -0
  15. mcdonald/flicker.py +340 -0
  16. mcdonald/forensics.py +885 -0
  17. mcdonald/groups.py +386 -0
  18. mcdonald/gui.py +135 -0
  19. mcdonald/icons/mcdonald-128.png +0 -0
  20. mcdonald/icons/mcdonald-16.png +0 -0
  21. mcdonald/icons/mcdonald-24.png +0 -0
  22. mcdonald/icons/mcdonald-256.png +0 -0
  23. mcdonald/icons/mcdonald-32.png +0 -0
  24. mcdonald/icons/mcdonald-48.png +0 -0
  25. mcdonald/icons/mcdonald-512.png +0 -0
  26. mcdonald/icons/mcdonald-64.png +0 -0
  27. mcdonald/icons/mcdonald-small.svg +15 -0
  28. mcdonald/icons/mcdonald.icns +0 -0
  29. mcdonald/icons/mcdonald.ico +0 -0
  30. mcdonald/icons/mcdonald.svg +19 -0
  31. mcdonald/integrity.py +810 -0
  32. mcdonald/kinematics.py +559 -0
  33. mcdonald/kinematics_cli.py +103 -0
  34. mcdonald/layers.py +500 -0
  35. mcdonald/look.py +411 -0
  36. mcdonald/mark.py +639 -0
  37. mcdonald/mark_qt.py +2908 -0
  38. mcdonald/measure_qt.py +763 -0
  39. mcdonald/progress.py +116 -0
  40. mcdonald/propose.py +902 -0
  41. mcdonald/pursue_videos.csv +145 -0
  42. mcdonald/readme_cli.py +53 -0
  43. mcdonald/reel.py +201 -0
  44. mcdonald/report.py +540 -0
  45. mcdonald/run.py +83 -0
  46. mcdonald/scale.py +175 -0
  47. mcdonald/setup_cli.py +174 -0
  48. mcdonald/stages.py +626 -0
  49. mcdonald/storage.py +86 -0
  50. mcdonald/symbology.py +725 -0
  51. mcdonald/tracksheet.py +230 -0
  52. mcdonald/update.py +250 -0
  53. mcdonald-0.2.4.dist-info/METADATA +102 -0
  54. mcdonald-0.2.4.dist-info/RECORD +58 -0
  55. mcdonald-0.2.4.dist-info/WHEEL +5 -0
  56. mcdonald-0.2.4.dist-info/entry_points.txt +5 -0
  57. mcdonald-0.2.4.dist-info/licenses/LICENSE +28 -0
  58. mcdonald-0.2.4.dist-info/top_level.txt +1 -0
mcdonald/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """McDonald UAP Toolkit — measurement tools for single-sensor video of
2
+ unidentified objects.
3
+
4
+ What the package is for: taking a clip of something unidentified and
5
+ establishing, frame by frame, what its motion in the image actually permits —
6
+ and, just as often, what it does not. Most of the discipline in here is about
7
+ the second part.
8
+
9
+ Three rules the tools are built to keep, because each one was broken once and
10
+ produced a wrong number:
11
+
12
+ 1. A rate is meaningless without naming what it is a rate *against*. Layers at
13
+ different ranges do not move together when the platform moves.
14
+ 2. Pixels per second become metres per second only through an angular scale k
15
+ and a range R. Neither is usually recoverable from a clip, and a speed
16
+ quoted without both sourced is not a measurement.
17
+ 3. A test that cannot decide has not passed. NO POWER is a verdict and is
18
+ reported, never dropped.
19
+
20
+ Named for James E. McDonald, who argued that the subject deserved ordinary
21
+ scientific instruments rather than either credulity or dismissal.
22
+ """
23
+ # The one place the version is written (pyproject reads it). It goes up every time changes go to
24
+ # main for others to install: pip --upgrade from GitHub does nothing while it stays the same.
25
+ # The date goes with it: the day that version went to main (Help -> About says both).
26
+ __version__ = "0.2.4"
27
+ __released__ = "2026-09-25"
28
+
29
+ from . import catalog # noqa: F401
30
+ from .clip import Clip, case_dir, out_prefix, probe, require_ffmpeg, resolve # noqa: F401
31
+
32
+ __all__ = ["catalog", "Clip", "case_dir", "out_prefix", "probe", "require_ffmpeg", "resolve",
33
+ "__version__", "__released__"]
mcdonald/actions.py ADDED
@@ -0,0 +1,266 @@
1
+ """What the marking windows do, written down once.
2
+
3
+ Every action either window offers -- its keys, what a menu calls it, one line
4
+ of help -- is a row of `ACTIONS`. The Qt window's menus and shortcuts, its
5
+ Help -> Keys page, the matplotlib window's key handler and the epilog of
6
+ `mcdonald mark --help` are all made from these rows, so a key cannot be bound
7
+ in one place and described differently in another. Before this table the list
8
+ existed three times by hand, and the person at the window could read none of
9
+ them: they were in two docstrings and a `keyPressEvent`.
10
+
11
+ No toolkit is imported here. A window binds a row to something it can do by
12
+ the row's id (`handlers()` in each), and tests/test_gui.py presses every key
13
+ of every row at each window and checks that the row's handler ran, and only
14
+ that one.
15
+
16
+ Keys are spelled as Qt's portable key sequences are ("Ctrl+Shift+Z",
17
+ "Backspace", ","), since that is also what a menu shows; `mpl_key` gives the
18
+ name matplotlib reports for the same key. On macOS Qt reads "Ctrl" as the
19
+ command key and draws it as one, which is what a person there expects.
20
+ """
21
+ import textwrap
22
+ from typing import NamedTuple
23
+
24
+ from .mark import CLASSES
25
+
26
+ MENUS = ["File", "Edit", "Mark", "View", "Go", "Track", "Measure", "Help"]
27
+
28
+
29
+ class Group(NamedTuple):
30
+ """Rows that a list of keys shows as one line: the six classes, the four arrows."""
31
+ keys: str
32
+ help: str
33
+
34
+
35
+ class Action(NamedTuple):
36
+ id: str # what a window's handlers are keyed by
37
+ menu: str # one of MENUS; "Edit>Nudge the mark" is a submenu
38
+ text: str # what the menu calls it
39
+ keys: tuple # the first is the one a menu shows
40
+ help: str # one line: the status bar under a menu, Help -> Keys, --help
41
+ mpl: bool = False # the matplotlib window has it too
42
+ check: bool = False # a state that is on or off, shown ticked
43
+ sep: bool = False # a separator above it in the menu
44
+ group: Group = None
45
+
46
+
47
+ class Gesture(NamedTuple):
48
+ """The mouse. Listed with the keys, bound by the windows themselves."""
49
+ keys: str
50
+ help: str
51
+ mpl: bool = False
52
+
53
+
54
+ SNAP_PX = 12.0 # how far shift+click reaches for a spot; the window's, and the help's
55
+
56
+ # Everything below that a person reads is written in plain words (docs/handoff-ui.md, "The player,
57
+ # and plain words", has the vocabulary; tests/test_gui.py's drive_plain_words holds it): a video, not a clip; a spot the computer found, not a detector's candidate; a kind of
58
+ # mark, not a class. "frame", "mark", "track" and "link" are the four words of the trade that are
59
+ # kept, and Help -> Getting started says what each means before it uses them.
60
+ _WHAT = {"object": "the object", "object2": "a second object",
61
+ "boresight": "the boresight: the cross that shows where the camera points",
62
+ "north": "the north arrow, if the screen shows one",
63
+ "reference": "a thing whose true size you know",
64
+ "horizon": "the horizon: the line between sky and ground"}
65
+ _CLASS = Group("1..%d" % len(CLASSES), "choose what your clicks mark: " + ", ".join(CLASSES).replace("object2", "object #2"))
66
+ _NUDGE = Group("Ctrl+arrows", "move this mark by 1 pixel; a mark you move this way counts as placed by hand")
67
+ _FINE = Group("Ctrl+Shift+arrows", "move it by a tenth of a pixel")
68
+ _ARROWS = (("left", "Left", -1, 0), ("right", "Right", 1, 0), ("up", "Up", 0, -1), ("down", "Down", 0, 1))
69
+ NUDGES = {f"nudge_{name}{'_fine' if fine else ''}": (dx * (0.1 if fine else 1.0), dy * (0.1 if fine else 1.0))
70
+ for fine in (False, True) for name, _, dx, dy in _ARROWS}
71
+
72
+ ACTIONS = [
73
+ # what the command line does with an argument or a flag, for someone who has no command line
74
+ Action("open_clip", "File", "Open a video…", ("Ctrl+O",), "open another video: choose the file, then the segment of it to open"),
75
+ Action("open_id", "File", "Open by catalog name…", ("Ctrl+Shift+O",),
76
+ "open a video by its short name in a catalog, such as PR149 or PR144. A catalog is a list of videos that "
77
+ "says which file each name stands for"),
78
+ Action("open_marks", "File", "Open marks…", (), "go on from marks you saved before: a file whose name ends in _marks.json"),
79
+ Action("save", "File", "Save", ("S", "Ctrl+S"),
80
+ "save your marks, the track, and a strip of small pictures that shows each mark on its frame, so you can check them",
81
+ mpl=True, sep=True),
82
+ Action("save_to", "File", "Save to a different folder…", ("Ctrl+Shift+S",),
83
+ "choose the folder where everything for this video is saved, and save there"),
84
+ Action("quit", "File", "Save and quit", ("Q", "Ctrl+Q"), "save and close the window", mpl=True, sep=True),
85
+
86
+ Action("undo", "Edit", "Undo", ("Ctrl+Z",), "undo your last change to the marks"),
87
+ Action("redo", "Edit", "Redo", ("Ctrl+Shift+Z", "Ctrl+Y"), "do again what you just undid"),
88
+ Action("delete", "Edit", "Delete this mark", ("Backspace", "Delete"), "delete the mark of this kind on this frame",
89
+ mpl=True, sep=True),
90
+ *(Action(f"nudge_{name}{'_fine' if fine else ''}", "Edit>Move the mark a little",
91
+ f"{name.capitalize()} {'0.1' if fine else '1'} pixel", (("Ctrl+Shift+" if fine else "Ctrl+") + key,),
92
+ "", sep=fine and name == "left", group=_FINE if fine else _NUDGE)
93
+ for fine in (False, True) for name, key, _, _ in _ARROWS),
94
+
95
+ *(Action(f"class_{i + 1}", "Mark", c.replace("object2", "object #2"), (str(i + 1),), f"your clicks mark {_WHAT[c]}",
96
+ mpl=True, check=True, group=_CLASS) for i, c in enumerate(CLASSES)),
97
+
98
+ Action("fit", "View", "Fit the frame to the window", ("R",), "show the whole frame, as large as the window allows", mpl=True),
99
+ Action("overview", "View", "Overview of the whole video", ("O",),
100
+ "overview: small pictures from the whole video; click one to go there"),
101
+ Action("candidates", "View", "Show spots the computer finds", ("C",),
102
+ "put a ring on each small bright or dark spot the computer finds on this frame. The first time is slow: it "
103
+ "first works out which parts of the picture never change", check=True, sep=True),
104
+ Action("other_frames", "View", "Marks of this kind on the other frames", ("T",),
105
+ "show or hide the marks of this kind that are on the other frames", check=True),
106
+
107
+ Action("prev", "Go", "Back one frame", (",", "Left"), "go back one frame", mpl=True),
108
+ Action("next", "Go", "On one frame", (".", "Right"), "go on one frame", mpl=True),
109
+ Action("back10", "Go", "Back 10 frames", ("<", "Shift+Left"), "go back 10 frames", mpl=True),
110
+ Action("on10", "Go", "On 10 frames", (">", "Shift+Right"), "go on 10 frames", mpl=True),
111
+ Action("first", "Go", "First frame", ("Home",), "go to the first frame", mpl=True),
112
+ Action("last", "Go", "Last frame", ("End",), "go to the last frame", mpl=True),
113
+ Action("prev_marked", "Go", "Back to a marked frame", ("[",), "go back to the nearest frame that has a mark of this kind",
114
+ sep=True),
115
+ Action("next_marked", "Go", "On to a marked frame", ("]",), "go on to the nearest frame that has a mark of this kind"),
116
+ Action("play", "Go", "Play or stop", ("Space",), "play the video at its true speed, or stop it", sep=True),
117
+ Action("slower", "Go", "Slower", ("-",), "play slower"),
118
+ Action("faster", "Go", "Faster", ("=", "+"), "play faster"),
119
+
120
+ Action("find", "Track", "Find the object…", ("F",),
121
+ "find: the computer looks for things that move against the background and lists them, the most likely first, "
122
+ "each as a strip of small pictures cut from the video. If the object is on the list, choose it: marks are put "
123
+ "along its path, saved as proposed and never as placed by hand, and linking starts. If it is not there, click "
124
+ "the object yourself. The computer only offers; you say which thing is the object"),
125
+ Action("link", "Track", "Link from the marks, or stop", ("L",),
126
+ "link: the computer follows the object from your marks, forward and backward from each, and draws the track as "
127
+ "it grows. Press again to stop. It chooses the size of spot to look for from your marks. Frames where forward "
128
+ "and backward do not agree are shown in orange: look at those. If it loses the object, mark the object where "
129
+ "you see it again and link again -- only the new frames are worked out"),
130
+
131
+ Action("measure", "Measure", "Measure this video…", ("M",),
132
+ "measure: run every measuring step on this video, with the track linked from your marks, and write one report "
133
+ "-- what `mcdonald run` does. It shows you the track sheet first and asks if the track is on the object in "
134
+ "every frame, because every number after that needs it to be"),
135
+ Action("report", "Measure", "Show the report", ("Ctrl+R",),
136
+ "the report for this video, once there is one: what was measured, what this video cannot tell us, and what "
137
+ "would settle it"),
138
+ Action("folder", "Measure", "Open the results folder", (),
139
+ "open the folder where everything for this video is saved", sep=True),
140
+
141
+ Action("first_run", "Help", "Getting started", (), "the job, step by step: find the object, click it twice, link, look, "
142
+ "save, measure, read the report"),
143
+ Action("keys", "Help", "Keys and mouse", ("F1",), "this list"),
144
+ Action("desktop", "Help", "Add mcdonald to the applications menu", (),
145
+ "add mcdonald to the applications menu, so that you can start it from the desktop with no command line (Linux "
146
+ "only; `mcdonald-gui --desktop-entry` does the same)",
147
+ sep=True),
148
+ Action("about", "Help", "About mcdonald", (), "the version, when it came out, and the license"),
149
+ ]
150
+
151
+ # Help -> About, in the README's own words: test_reduction holds each to README.md.
152
+ ABOUT = ("mcdonald is a frame-by-frame analysis toolkit for measuring the kinematics of an unknown object in a "
153
+ "single-camera video.")
154
+ QUOTE = ("Science is in default for having failed to mount any truly adequate studies of this problem.",
155
+ "James E. McDonald")
156
+ COPYRIGHT = "Copyright (c) 2026 Jacob Haqq Misra. Released under the BSD 3-Clause License."
157
+ HOME = "https://github.com/haqqmisra/mcdonald"
158
+
159
+ GESTURES = [
160
+ Gesture("click", "place a mark of the kind you chose", mpl=True),
161
+ Gesture("shift+click", f"the same, but the mark goes on the nearest spot the computer found (within {SNAP_PX:g} pixels) "
162
+ "at the spot size shown. The mark is saved as snapped, and never counts as placed by hand"),
163
+ Gesture("scroll", "zoom in or out around the mouse pointer", mpl=True),
164
+ Gesture("middle-drag", "move the picture", mpl=True),
165
+ Gesture("right-drag, ctrl+drag", "move the picture, for a mouse with no middle button"),
166
+ ]
167
+
168
+ MPL_NOTE = ("The matplotlib window's toolbar can zoom and move the picture too. While one of its tools is on, clicks do "
169
+ "not place marks.")
170
+
171
+ # Help -> Getting started: the job, in the order it is done, for someone who has only the window.
172
+ # A key is named by its row -- {link} -- so the page cannot come to say a key the menus do not.
173
+ FIRST_RUN = [
174
+ ("Four words",
175
+ "A video is a row of still pictures, called frames. A mark is a click that says: on this frame, the object is here. "
176
+ "A track is the place of the object on every frame. To link is to let the computer follow the object from your "
177
+ "marks, and so make a track."),
178
+ ("Let the computer look first",
179
+ "Press {find}. The computer looks for things that move against the background and lists them, the most likely "
180
+ "first. Each is shown as a strip of small pictures cut from the video. If the object is on the list, press This is it "
181
+ "next to it, and go on to Look at the strip, below. The computer only offers. Other things move too, such as "
182
+ "numbers that slide across the screen. If the object is faint, is seen for only a moment, or is one of many "
183
+ "moving things, it may not be on the list at all. Then find it yourself:"),
184
+ ("Find when",
185
+ "Open the overview ({overview}): small pictures from the whole video. Click the one where you see something, and "
186
+ "the window goes there. {play} plays the video at its true speed. {prev} and {next} go back or on one frame. You "
187
+ "can also drag the bar under the picture."),
188
+ ("Find where",
189
+ "Scroll to zoom in around the mouse pointer. The close-up at the top right shows the pixels under the pointer. If "
190
+ "you cannot tell which spot is the object, {candidates} puts a ring on each spot the computer finds on this frame. "
191
+ "The first time is slow, once for each video."),
192
+ ("Two clicks",
193
+ "Click the object. Go on a few frames and click it again. One mark says which thing it is. Two marks also give its "
194
+ "speed and direction, which a fast object needs. This is the one thing the computer cannot do for you: it cannot "
195
+ "know which thing in the picture is the object, and a person who looks can. {delete} takes a mark away, and {undo} "
196
+ "undoes your last change."),
197
+ ("Link",
198
+ "Press {link}. The computer follows the object from your marks, forward and backward from each one, and draws the "
199
+ "track as it grows. Frames where forward and backward do not agree are shown in orange: look at those. If it "
200
+ "loses the object, mark the object where you see it again and link again. Only the new frames are worked out."),
201
+ ("Look at the strip",
202
+ "When linking ends, a strip of small pictures opens, one for each place on the track. Every picture should show "
203
+ "the same thing. A track that sits on a bit of cloud for a few frames gives a clean but wrong speed, and only "
204
+ "looking can catch that."),
205
+ ("Save",
206
+ "Press {save} to save the marks, the track, and a strip that shows every mark drawn on its frame. The strip is then "
207
+ "shown to you. Until you have seen a mark drawn on the picture, you are trusting a number; you have not checked "
208
+ "it. The window says which folder it saves to, at the bottom right."),
209
+ ("Measure",
210
+ "Press {measure} to measure the video: how the background moves, how the object moves against each part of the "
211
+ "background, what that motion allows, and whether the object acts like part of the picture or like something "
212
+ "laid over it. First you are shown the track sheet, one small picture for every frame, and asked if the circle is "
213
+ "on the object in every one. Say no if you cannot tell. Fill in only what you know, such as how wide the camera "
214
+ "sees or how far away the object was, and leave the rest empty."),
215
+ ("Read the report",
216
+ "The report opens when measuring ends, and {report} opens it again. Read the part called \"What this clip cannot "
217
+ "decide\" before the last line of the report: a test that could not decide has not passed. The part called \"What "
218
+ "would close it\" names what is missing."),
219
+ ]
220
+
221
+
222
+ def first_run(key=str):
223
+ """FIRST_RUN with each {row id} replaced by that row's key as the menus show it;
224
+ `key` dresses a key for the page it is going onto."""
225
+ keys = {a.id: key(spoken(a.keys[0]) if a.keys else f"{a.menu} -> {a.text}") for a in ACTIONS}
226
+ return [(head, text.format(**keys)) for head, text in FIRST_RUN]
227
+
228
+
229
+ def for_window(window):
230
+ """The rows a window has: 'qt' has them all, 'mpl' those marked."""
231
+ return [a for a in ACTIONS if window == "qt" or a.mpl]
232
+
233
+
234
+ def mpl_key(key):
235
+ """What matplotlib calls a key: 'Ctrl+S' is 'ctrl+s', 'Backspace' 'backspace'."""
236
+ return key.lower()
237
+
238
+
239
+ def spoken(key):
240
+ """A key as a person reads it: 'S' in a list of keys would be read as shift+s."""
241
+ return key.lower()
242
+
243
+
244
+ def listing(window="qt"):
245
+ """[(keys, help, the matplotlib window has it)], one entry per line of a key
246
+ list: the mouse first, then the rows in menu order, a group once."""
247
+ out, seen = [(g.keys, g.help, g.mpl) for g in GESTURES if window == "qt" or g.mpl], set()
248
+ for a in for_window(window):
249
+ if a.group is None: # a row with no key is found in its menu
250
+ out.append((" ".join(spoken(k) for k in a.keys) or f"{a.menu} menu", a.help, a.mpl))
251
+ elif a.group not in seen:
252
+ seen.add(a.group)
253
+ out.append((spoken(a.group.keys), a.group.help, a.mpl))
254
+ return out
255
+
256
+
257
+ def controls(width=100):
258
+ """The key list as text, for `mcdonald mark --help`."""
259
+ rows = listing("qt")
260
+ pad = max(len(k) for k, _, _ in rows) + 2
261
+ L = ["Keys and mouse (* the Qt window only)", ""]
262
+ for keys, text, mpl in rows:
263
+ body = textwrap.wrap(text, width - pad - 6, break_on_hyphens=False) or [""]
264
+ L.append(f" {' ' if mpl else '*'} {keys:<{pad}}{body[0]}")
265
+ L += [" " * (pad + 4) + ln for ln in body[1:]]
266
+ return "\n".join(L + ["", MPL_NOTE, ""])