NEWS


rplatypus 2026-11-05

vignette("classification"), and it is the fourth task's first worked example on either side. Nothing in the package changed and the pin did not move: the page is built out of what is already here, which is the point of it.

It continues the detection vignette. detection_crops() cuts the published bccd-yolo3 detector's boxes out of the BCCD photographs, a dozen lines label each crop by what it overlaps and write them to one directory per class, and classification_data() reads that layout straight. Then classifier(), class_balance() before the fit rather than after it, and predict() for the probabilities a threshold is chosen from.

It is the only page here whose subject fails, and that is why it is worth reading. The classifier learns the three cell types nearly perfectly - 637 of 638 red cells - and cannot do the one thing it was added for, which is reject the boxes that match no annotation: 32 of 312. The page measures why rather than asserting it. Those crops are red cells that BCCD does not annotate, at a median detector score of 0.9864, so the label is a statement about the annotation and not about the picture; and the detector's own operating_point, one number already in the specification, reaches the same precision for the same cost with nothing trained.

One thing it found reaches a page that already shipped: a precision measured against an incomplete annotation is a lower bound, so vignette("blood-cells")'s per-class precision understates the detector. It is said on the new page.

Two corrections to the README came with it. Multi-label classification is no longer listed as missing - labels_from has been there since the engine's 0.8.0a50 - and the section that lists what is in the package now says so.

rplatypus 2026-11-03

R CMD check --as-cran reported a WARNING, and had done since 2026-10-29. tests/testthat/test-runnable-examples.R calls codetools::findGlobals() to walk a function's call graph, and codetools was not in Suggests - it is a recommended package rather than a base one, so the check wants it declared:

* checking for unstated dependencies in 'tests' ... WARNING
'::' or ':::' import not declared from: 'codetools'

It matters because the default branch is the CRAN submission candidate and CRAN does not accept a WARNING. With codetools declared, the same run gives 1 WARNING -> 0, leaving the three NOTEs this project already expects.

Nothing caught it because none of the gates run before a push use --as-cran: the suite does not look at DESCRIPTION, and R CMD check --no-manual does not imply it.

And the guard stopped swallowing an error. The scan's own helper turned a failure of codetools::findGlobals into character(), which reads as "this function calls nothing"

The engine: none. rplatypus#268.

rplatypus 2026-10-29

The review agent's first audit produced nine findings and three of them are R's. maju116/rplatypus#248 and #249 are here; #247 waits on the engine release that carries its fix. The whole audit is in the workbench's REVIEWER_FINDINGS.md, and each finding was reproduced independently before it was filed.

Fixed

rplatypus 2026-10-25

The PyTorch build can be chosen once. options(rplatypus.torch = "pascal") in ~/.Rprofile, or PLATYPUS_TORCH=pascal in ~/.Renviron for runs that never load one - Rscript, a scheduler, CI. Both are read when the package loads, which is the last moment anything can change what the environment will be.

Until now the only way was to call [platypus_use_torch()] at the start of every session, because py_require() is session state. On a card older than Turing, forgetting it costs about ten times the training time without costing an error - the run completes, the numbers are right, and the card sits idle. That is the kind of bill that goes unnoticed.

The option wins over the variable, so a session can override what a machine was configured with; "default" turns the setting off for one session without editing the file it came from; and a value neither recognises is reported at load and ignored, because a bad line in a startup file should not make the package unloadable.

An engine that is not the pinned one now says so. The pin is what keeps an R release from breaking when the engine moves underneath it, and on one route it was never in force: a RETICULATE_PYTHON pointing at somebody's own environment brings whatever pyplatypus is in it, and nothing compared the two. The failure arrived as a validator refusing a configuration field by name, which tells a reader nothing about their interpreter.

A warning rather than a refusal, naming both versions: the only way to reach this state is to have gone out of one's way, and refusing would also break somebody deliberately testing an engine they built. PLATYPUS_ENGINE_PATH is exempt - that route asks for a source tree with no version at all.

maju116/rplatypus#211, #214. No engine release: both are about what an R session settles before Python starts.

rplatypus 2026-10-22

The pin does not move and the engine needs no release: this is the first thing in the package that has nothing to do with segmentation. The family is named after the animal, and platypus_fact() says something true about it. One fact is also printed when the package is attached in an interactive session; options(rplatypus.facts = FALSE), or PLATYPUS_FACTS=0 in ~/.Renviron, stops that.

Three things about it are deliberate, and each one is here because of what it prevents.

Choosing a fact does not touch the random number stream. sample() advances .Random.seed, so a fact picked at attach time changes what the user's own set.seed(1) then produces - measured, runif(1) goes from 0.2655087 to 0.3721239 - and in a package offering seeded splits and deterministic = TRUE that is the worst available place for it. suppressPackageStartupMessages() is not a defence: the arguments are evaluated before the condition is signalled, so suppressing the message keeps the side effect and removes the evidence. The seed is saved and put back instead, and the test for it carries the naive implementation as its positive control.

Interactive sessions only, so a fact never lands in an R CMD check log, a CI run, a knitr render or anybody's reverse-dependency check. And .onAttach rather than .onLoad, which is where this package's other startup work lives: .onLoad also runs for rplatypus::fun(), which would print a platypus fact into the middle of a script that never called library().

Every fact carries the source it was read from, and the numbering is stable. Four of the twenty-five exist to correct a popular claim rather than to state one - the platypus does have a stomach, it does not grind food with gravel, "platypi" is not a plural and there is no accepted word for a baby platypus - which is the only form this could take in a package whose argument is that numbers should be measured rather than felt. A test holds every entry to having a source. The numbers are published the moment somebody quotes one, so facts are appended and never reordered, and a ledger of each fact's opening in tests/testthat/test-facts.R fails if one moves.

tools/reference-groups.yml gains a group, The animal, last in the order. The matching line went into pyplatypus/tests/test_reference_groups.py at the same time: the shared order is an agreement about where a group goes rather than a list both sides fill, so the engine carries the title with no contents, as this package does for Records.

rplatypus 2026-10-16

Needs pyplatypus 0.8.0a28, and the pin moves with this. maju116/rplatypus#180, whose engine half is maju116/pyplatypus#91 - an issue open since 2022, and this is the half the author's decision of 2026-10-12 deferred rather than dropped.

Added

Fixed

Documented

Needs pyplatypus 0.8.0a27, and the pin moves with this. Nothing in this package gains a function: the engine fixed a defect in the release it shipped metric_surface_dice() in, and what this package owes is one corrected sentence and the test that would have caught it.

Fixed

Needs pyplatypus 0.8.0a26, and the pin moves with this.

Added

Changed

rplatypus 2026-10-15

Needs pyplatypus 0.8.0a25, which is already the pin.

Added

Fixed

Needs pyplatypus 0.8.0a25, which is what the pin moves to here.

Added

Changed

Needs pyplatypus 0.8.0a24, which is already the pin.

Added

Changed

Needs pyplatypus 0.8.0a24.

Changed

Added

Fixed

Measured

Added

Measured

Fixed

rplatypus 2026-10-14

Added

Fixed

[0.2.0] - submitted to CRAN on 2026-10-14

A different package from 0.1.1 under the same name, and the section below says how different.

Changed

rplatypus 2026-10-07

rplatypus 0.8.0

The pin moves to 0.8.0a68, and what it carries is one column of evaluate() meaning what everybody already read it as. best_epoch was the epoch with the lowest validation loss, read off the run's history whatever a callback was watching, while the scores beside it come from the weights callback_early_stopping(restore_best = TRUE) kept - the best monitored epoch. Those are the same epoch whenever monitor is left at its default of "val_loss", and different exactly when you set it to something else.

It is now the epoch the row's own scores came from, and empty rather than wrong when they came from no single epoch: an average left by callback_ema() or callback_swa(), or a run that selected nothing and therefore ended on its last epoch, which epochs_run already names. The epoch that was best by some other quantity is in training_history(fit), which is where it always was.

vignette("classification") is re-knitted, because it described the defect. That page printed both numbers - 29 restored, 18 reported - and explained the gap, which is how anybody noticed; the paragraph now says what the column is and that this page is the reason. Nothing else on it changed in kind, and its figures are a fresh run's.

The pin moves to 0.8.0a67, the reviewer's eleventh pass, and nothing in it changes what this package accepts. One defect reaches you through the pin and by nothing else: a PALETTE COLOR DICOM whose lookup table is declared 16-bit while its entries only reach 255 came back almost black - through read_images() and through a run's own reading of your data alike - because the colour was scaled by what the table's type can hold rather than by what the table holds. Such files exist - it is a writer quirk that pydicom documents - and 0.8.0a66, which fixed palette files being read as their raw indices, introduced this narrower case while doing it. It is scaled correctly now, and it says so rather than rescaling in silence. A window passed for a palette file also warns instead of being dropped without a word: an index carries no ordering, so there is nothing to window.

And u_net(..., batch_normalization = FALSE) is held by a test for the first time, along with twenty other model settings. Nothing changes in what any of them does. What changed is that nine of the twenty fields the constructors forward to the engine could have stopped being forwarded with the whole suite still green - the value was assembled correctly and nothing asserted it - and six of those have an engine-side guard that cannot see a field which never arrives. One test now reads every constructor's own arguments against the schema, so a field added later is either covered or named with a reason.

The CRAN entry is live, and README.md and this file said otherwise until today: install.packages("rplatypus") gives 0.2.1, published 2026-11-04, while remotes::install_github("maju116/rplatypus") gives the default branch, which is ahead of it between submissions.

The pin moves to 0.8.0a66, the reviewer's tenth pass, and one thing in it changes what this package accepts: normalise = TRUE without encoder is refused rather than quietly ignored, by the engine's validator and with a message naming the field. The statistics it applies belong to a backbone, and the built-in encoder has none - so setting it without one asked for something that could not happen. It applies to all five constructors that take one: u_net(), linknet(), res_u_net(), u_net_plus_plus() and classifier(). It arrived a day earlier, in 0.8.0a65, accepted and doing nothing; nothing can depend on the old behaviour. normalise = FALSE without an encoder is still fine: it asks for nothing.

Two defects reach you through the pin and by nothing else. Weights from hf://owner/repo/file@commit can be used offline again - the weights = argument of any model constructor, beside the names available_weights() lists - when the repository publishes no description beside them - 0.8.0a65 had started refusing that, which took away the one route that form exists for, because the hub raises the same error for a file it cannot reach and for one it has already recorded as absent. And a PALETTE COLOR DICOM is read through its lookup table instead of as its palette indices: such a file used to come back as the index numbers, windowed, so a red-and-blue picture was black and white and a model trained on it learned from an ordering the file never claimed.

Nothing else here is behaviour. Six of the pass's nine findings were guards for lines that had none, which is why this entry is short against a release of nine issues: the largest of them is that a model setting could be read out of your specification and dropped on the way to the network with every test still passing. Two of those lines were this package's own - normalise reaching the request from each constructor - and they are held now. maju116/pyplatypus#384 to #392, and this side's half is maju116/rplatypus#278.

normalise is new on every model that takes an encoder - u_net(), linknet(), res_u_net(), u_net_plus_plus() and classifier(). It defaults to pretrained, so nothing changes unless you set it, and it exists for one case: reloading weights you exported from a pretrained encoder on a machine with no network. A pretrained backbone normalises its input with that backbone's ImageNet statistics, and until now those existed only when the weights were downloaded - so reloading your own weights meant fetching ImageNet weights to overwrite them, and offline it could not be done at all. Set normalise = TRUE with pretrained = FALSE and the statistics come from the backbone's configuration, which needs no download, while the weights stay yours.

predict() on a detector now drops boxes scored below score_threshold, which is what that argument always said it did. The engine was cutting on the network's objectness while reporting each box at objectness x class probability - a smaller number - so a box scored 0.06 survived a threshold of 0.5. This entry also said "and one scored 0.396 was dropped", which was wrong and is corrected rather than deleted (maju116/pyplatypus#390): 0.396 is below 0.5 and is dropped after the fix as well, so it was the control in the report rather than a second symptom. Nothing above the threshold was ever lost - the reported score never exceeds the objectness - so if you went looking for boxes in that position among your own results, there are none to find. Measured on the published bccd-yolo3 weights over their own 72 held-out images: 2,771 predictions become 2,537, and map_50, map_50_95 and mean_matched_iou are identical to six decimals, so no published figure moves. If you filtered the returned data frame yourself, you can stop; plot_boxes(min_score =) was already filtering again and is unaffected.

Two more arrive through the pin alone, both in the weights path. A description beside a published file that could not be reached - offline, or on a dropped connection with nothing cached - was being read as a file published without one, which skipped the colormap and class checks; it raises now. And a multi-frame DICOM whose header does not admit how many frames it holds is refused rather than read as a picture whose rows are the frames.

CVE-2026-24747 is in PyTorch, not in this package, and the part that reaches you is a warning. Below torch 2.10 the weights_only=True unpickler does not validate what it is reading, so a crafted checkpoint can corrupt memory and may reach code execution. Passing a .pth file to weights - a torch archive rather than safetensors - now warns, in R, naming the advisory and the version it was fixed in. Every set of weights this project publishes is safetensors, which has no such surface, so this is about files from elsewhere.

It is a warning rather than a refusal, and this package cannot do better than that for you. platypus_use_torch("pascal") exists because a GTX 10-series card needs a CUDA 12 build, and the engine can ask for a torch version without being able to ask for a build: py_require() takes no index argument. The last version whose default PyPI wheel serves that card is 2.7.x, which is inside the vulnerable range. Measured on such a card: torch==2.14.1 from https://download.pytorch.org/whl/cu126 is patched and works there, so the way out is to install that build yourself before the first run.

Also through the pin: a detection report method the engine no longer offers, which nothing in this package ever called.

RETICULATE_PYTHON is the one route the pin cannot hold, and the warning written for it reached almost nobody. The comparison between the engine that answered and the version this package pins was attached to the module import, which this package touches in exactly one place - platypus_status(). Every other call into the engine, all 44 of them, goes through the Python shim instead, and that route never triggered it. So a session pointed at somebody else's pyplatypus ran it without a word unless the user happened to ask for the status. The comparison now runs when the shim is first imported, once per session, which is the route every call takes. rplatypus#272.

Six fixes arrive through the pin alone, and the first two are in the detector's scoring. evaluate()'s map_50_95 was computed from a single ranking built at the lowest IoU threshold, which stopped describing the higher ones the moment a difficult truth could absorb a prediction - so the figure was wrong on exactly the Pascal VOC data last week's fix was about. map_50 was never affected, and neither was any published number: BCCD marks none of its objects difficult. The rule the matcher follows is also named correctly now - it is COCO's, which is what map_50_95 is defined against, rather than voc_eval's.

The other four are a reader being protected from a silent answer. Weights now refuse to load into a model whose activation differs from the one they were trained with, or whose encoder no longer normalises its input the way a pretrained one does: both change what the network computes while every tensor keeps its shape, so a published file used to load without a word and predict confidently. A description that fails to download beside its weights is no longer treated as weights published without one, which had skipped the colormap and class checks. A multi-frame DICOM file - an enhanced CT or MR, or a cine loop - is refused by name instead of being read as a picture whose rows are the frames. And read_record() can open a run that went to NaN: the engine was writing a token R's JSON parser rejects, so the record of the one run worth inspecting was the one this package could not read.

A model that FOUND a hard object scored below one that MISSED it. Pascal VOC marks some objects difficult and leaves them out of its own evaluation; the engine deleted them from the truth before matching, so a prediction that found one had nothing to match and was counted as a false positive. On two truths of one class with the second marked difficult, both detected and the hard one ranked first, evaluate() reported an average precision of 0.500 with one false positive, against 1.000 for a model that predicted only the ordinary object.

evaluate_images() gains an ignored column, and that is the part worth reading before a script breaks on it. A prediction attributed to a difficult truth belongs in neither of the two columns that were there: calling it matched would claim the model found something the benchmark left out, and calling it spurious would charge it for the same thing. So the counts now read n_predicted == matched + ignored + spurious, and the column is zero for any dataset that marks nothing difficult - which is every dataset this package ships an example for, BCCD included.

Two more fixes arrive through the pin alone, both in the LabelMe reader detection_data(annotation_format = "labelme") forwards to. A shape drawn with the point tool was refused as a malformed box rather than by name, so the message naming the tool to use instead was unreachable for the shape LabelMe actually writes. And a circle drawn against an edge gave a box reaching past the frame, which an augmented run clipped and an unaugmented one did not - one annotation, two different targets.

The engine: 0.8.0a62. rplatypus#270.

read_images(label_map = TRUE) on a volume returned the file's values divided by the largest one, and @return promised the values themselves. maju116/rplatypus#266. The engine documents label_map as ignored for volume formats - a NIfTI or a DICOM series never went through the 8-bit conversion that argument exists to widen - and on that route the flag meaning these numbers are classes is nearest, which volumes._rescale says in as many words. Measured through the shim on a NIfTI holding 0, 1, 2 and 7:

nearest = FALSE, label_map = TRUE    0, 0.1429, 0.2857, 1.0     the classes over 7
nearest = TRUE,  label_map = TRUE    0, 1, 2, 7                 the file's own values

The page says so now, and read_images() warns when it is asked for that combination, because it is always a mistake: the caller has asked for the file's values. Not a refusal, since nothing is read at that point and the function cannot know whether any of the paths is a volume. read_masks() already passed both flags, which is why nothing here ever warned.

The pin moves to 0.8.0a61 and this package needs nothing else from it. Six engine fixes, all defects an R user meets through the pin moving and by nothing else:

The pin moves to 0.8.0a60 and this package needs nothing else, which is the state §3E is written for - with the distinction 0.8.0a47 records, because all three of the release's fixes are defects an R user meets through the pin moving and by nothing else rather than features this package forwards.

The one worth knowing about is the detector's augmentation. platypus_fit(num_workers = "auto") resolves to min(4, cores - 1), so on any multi-core machine an augmented detector trained from R ran with workers - and every worker drew the same augmentation sequence, because the engine's worker hook asks the augmenter for a reseed by name and the box augmenter had none. A four-worker run therefore saw a quarter of the transforms it believed it saw, the BCCD recipe's flips and rotations among them. Silent: nothing in the shape of a run says so. maju116/pyplatypus#351.

Two more arrive with it. A detection epoch's reported loss - including the val_loss that callback_early_stopping(), callback_model_checkpoint() and callback_reduce_lr_on_plateau() watch - depended on batch_size whenever a grid was empty in some batch, so two runs at different batch sizes were not comparable (maju116/pyplatypus#352). And a mask stored as a palettised or a one-bit PNG, read through read_masks(labels = ), came back as the palette's luminance or as 0 and 255 rather than the values the file holds - the shape Pascal VOC's masks arrive in, and the one 0.8.0a59's widening could not reach (maju116/pyplatypus#353).

read_images() already forwards label_map, so the third needs nothing here either: the fix is in the reader the shim calls.

The pin moves to 0.8.0a59, and R needs something from the release rather than only through it. maju116/rplatypus#262: read_masks(labels = ) crosses to the engine's reader with channels = 1, and that reader converted every ordinary picture through 8-bit greyscale - so a 16-bit PNG or a 32-bit TIFF label map arrived with every value above 255 flattened to 255. Measured on the same file:

read as an image    0, 255              the two values a byte can hold
read as a label map 0, 255, 300, 1000   the values the file actually stores

With labels = c(0, 255, 1000) that made two bands one class and label_classes() reported nothing unmatched, so there was no symptom at all. read_images() takes label_map now and read_masks() passes it on the labels branch; the colormap branch is a picture and stays 8-bit, which is why this is an argument rather than the new default - an ordinary image is divided by 255 later.

And writing that turned up a second copy of a rule nothing was holding, which is maju116/rplatypus#263. label_classes() and the engine's labels_to_classes both compare a label map's values to within half a unit and both send a value no label claims to the first class, and nothing compared them: label_classes() is called in one place and no test in either suite mentioned the engine's function. test-label-classes-pair.R is that pair now, on the arrays where the two could disagree rather than the ones any implementation agrees on

What else 0.8.0a59 carries reaches R through the pin and by nothing else. A repeated key in a config_file CSV used to pass through as two samples with one name, so evaluate_cases() returned three rows for four images and predict(each = TRUE) returned one entry for two; the engine refuses it now, naming the keys, and points at evaluate_cases(group_by = ) for the pooling somebody repeating a key was reaching for. R sends the specification and the engine does the discovery, so there is nothing for this package to add.

The pin moves to 0.8.0a58, and this time R needs something from the release rather than only through it. maju116/rplatypus#259: inspect_masks() named a class when the specification used regions and returned the bare index for the other three namings - while named_classes' own documentation said it mirrored the engine's _describe_classes, which names a region, a classes_from pattern, a labels value and a colormap colour. So the claim was false for three namings out of four, and a classes_from user asking which class was missing got 1 where the engine's own refusal for the same data says "class 1 (files matching '_organ\.png$')" - which ?inspect_masks calls the one thing a reader cannot work out for themselves.

What the fix turned on is a distinction the finding did not have, and a measurement is what found it. Forwarding _describe_classes - "name it the way the engine does" - turned five of this package's own assertions from "one, both" into "region 'one' (of [1]), region 'both' (of [1, 2])". That sentence is written for a refusal; it reads well in a message and badly in a column, and save_class_masks() writes files from these names. Two jobs, conflated.

So 0.8.0a58 adds SegmentationData.class_names - one short label per class, a region's name or a classes_from pattern, and the index for labels and colormap where the number is the class - and this package forwards to it instead of deriving anything. The engine's refusal keeps its sentence. §3A decides where the rule lives: the engine's name wins.

And four engine fixes reach an R user through the pin moving and by nothing else. maju116/pyplatypus#341 is the one worth reading: seed promised that a run's augmentation was repeatable and nothing seeded the augmentation pipeline, so two runs at one seed differed whenever a model augmented - deterministic = TRUE included. ?platypus_spec carries the same promise, which is now true. The other three: loss_boundary(region = loss_lovasz()) was accepted beside regions or classes_from where loss_lovasz() alone is refused; a callback_model_checkpoint() condition on a val_ quantity was accepted under validation = FALSE and died with a bare KeyError after one epoch; and a refusal quoted an unmatched-voxel share that the engine itself calls meaningless for those namings.

The field-prose ledger was regenerated: 116 fields, and the recorded engine version is the whole diff - no description moved, so no @param needed re-reading.

The pin moves to 0.8.0a57 and this package gains nothing else. None of that release's four findings is a surface an R user calls, and all four are defects an R user meets through the pin moving and by nothing else - which is §3E's distinction rather than its default, the same shape as 0.8.0a47 and a48. maju116/pyplatypus#333 to #336.

Two of them reach R through the order a user writes their callbacks in, and this package sends that order unchanged. callback_early_stopping(restore_best = TRUE) beside callback_ema() ended a run on the raw best epoch or on the average depending on which came first - 0.578 apart in the largest weight on one run with nothing but the order changed - while the record reported the EMA steps either way. And the epoch loop asked any() over a generator, which short-circuits, so a callback_csv_logger() written after a stopper lost the run's final row, and callback_stop_after() written before callback_early_stopping() restored the second-best epoch rather than the best. The averager wins now, stated rather than inherited, and every callback sees every epoch.

The other two are narrower. callback_swa() resolved its start fraction by a different rounding rule from every other callback's start, so the same fraction began an epoch later whenever the arithmetic landed on a half - at 10 epochs, start = 0.75 folded two epochs where the shared rule folds three. And the detector's letterbox pasted the image at a rounded offset while the boxes used the exact one, so every letterboxed frame and its targets were up to a pixel apart; predict() on a detector returns boxes in each image's own pixels, so the inverse carried that offset back out.

And the field-prose ledger found something, which is the third time it has. Regenerating it for the new pin reported restore_best's description rewritten in the engine's callbacks schema, and re-reading R's own @param for it - the only thing that step is for

Five findings from the reviewer's second pass. Four are the same shape - this package answered a question the engine already answers, and the two disagreed - and the fifth is this package's own arithmetic. No engine release: the pin stays at 0.8.0a56 because every fix is on this side.

predict() with type = "class" was refused for regions and computed for classes_from. maju116/rplatypus#252. Both namings make a pixel's classes independent - the engine has one switch for it and its own documentation says everything downstream reads that rather than either field - and this package read regions. So a classes_from fit returned the most likely channel over sigmoids with no background channel, which gives every background pixel a class. Nothing downstream could see it: save_class_masks() detects an overlap from a probability array and a class map has none.

Measured on a 4-sample 32x32 fixture with two binary mask files each: the call returned a 4 x 32 x 32 array holding only 1 and 2, with a pixel outside both masks given class 2 on probabilities of 0.497-0.500 and 0.499-0.527. The control is the same pictures written as one label map with regions, which was refused. The refusal also names the naming the caller actually wrote now, where it said regions whatever had been given.

print() of a specification said classes : 0 for three of the four namings. maju116/rplatypus#256. It counted the colormap's length, which only colormap fills, so labels, regions and classes_from all printed zero on the one summary a user prints first - and it reads as the classes were not understood. The count comes from the engine now: labels = c(0, 1, 2) prints 3, with voc_colormap's 21 as the control.

A misspelt key in split was accepted here and refused by name in a YAML file. maju116/rplatypus#253. The block was rebuilt from fractions, group_by and seed and everything else dropped, so sed = 7 reached the engine as a seed of 0 - an unseeded run whose author believes it is seeded. Worse, $ on an R list matches a name by prefix, so seeds = 7 was read as seed and simply worked. Both are refused by the engine now, because unknown keys are forwarded rather than discarded, and [[ replaces $.

The two halves are separate claims and the forwarding one hides the other, which the mutation batch is what showed: with every key forwarded, putting $ back failed nothing. So the prefix is asserted against what this package sends rather than against the engine's answer - and the first draft of that test used $ and reported the field as set, which is the defect demonstrating itself.

A fractional whole number was truncated in silence. maju116/rplatypus#254. as.integer(1.5) is 1, so labels = c(0, 1.5) became c(0, 1) and a colormap entry of 255.9 became 255 - a different valid value rather than a message, where the engine refuses a number with a fractional part by name. The comment above that coercion had always said it was there so nobody "gets a puzzling message from a library they never invoked", which described the intention and not the code. The test is trunc, which segmentation_data() has always used for regions.

And one finding is not about the seam at all: mask_coverage() hid a class with no pixels. maju116/rplatypus#255. tabulate() sizes its table by the largest value it sees, so a declared class above that got no row - and ?mask_coverage's own example says a class with 0 pixels "is the thing to look for". With labels = c("bg", "liver", "tumour") a mask holding only 1 and 2 returned two rows; a mask holding 1 and 3 returned three, with liver at 0, which is the control that says the last row was the only one affected. The highest-numbered class is the usual position of a rare foreground, so the row that disappeared was the one worth reading. The count now comes from n_class if given, then from labels, and only then from the mask.

No field's description moved, so the field-prose ledger is unchanged and no @param needed re-reading.

The pin moves to 0.8.0a56, which is a defect reaching an R user and nothing else. maju116/rplatypus#247: evaluate() and evaluate_cases() with at = "source" raised a ValueError for cldice and surface_dice, naming neither the metric nor at - and ?evaluate calls that route "the answer to that warning". The whole fix is the engine's, so this is §3E's None beyond the pin in the version that costs somebody something, the same shape as 0.8.0a47 and a48.

Measured on the engine's side before the release: four 64x64 cases with [dice] returned a number on both grids while [dice, cldice] raised on at = "source" from both functions, with the dice-only row as the control. After: cldice 0.28 on the model grid and 0.4839 at source, which is two grids giving two numbers - what the route exists for.

It is a56 rather than a55 because a55 carried a regression this package found. 0.8.0a55 made the engine's IoU matrix index the object numbers present rather than 1..max, and the engine's own agreement overlay still mapped a row back with row + 1 - so an agreement figure over a label map with a gap coloured the wrong objects. What caught it is test-instance-drawing.R, which compares overlay_instance_agreement()'s output with this package's own pixel for pixel and carries a fixture named gapped, whose comment says that taking seq_len(max(labels)) instead of the values present changes nothing - true here, and untrue there from that release until a56.

So this package held the correct copy of that rule the whole time: matched_objects() has always taken sort(unique(...)) of each side. That is the second time the engine was the wrong copy and the first time in code rather than prose, and the pair is what reported it.

The field-prose ledger was regenerated for the new pin and the diff is two lines, both the recorded engine version: no field's description moved, so no @param needed re-reading.

The pin moves to 0.8.0a53, one move for two releases. 0.8.0a52 made an unlabelled classification test split reachable - a CSV of key, images, which is the ordinary shape of a prediction set - and this package needed a representation for it. a53 is a defect an R user meets through predict(fit, split = "train") and whose fix arrives by the pin moving and by nothing else, so the ratchet carries both rather than moving twice. maju116/rplatypus#245, maju116/pyplatypus#320 and #321.

Fixed

Changed

The pin moves to 0.8.0a51, one move for two releases. 0.8.0a50 shipped labels_from and this package needed it; a51 fixed two defects this package needed nothing from, so the ratchet carries it along rather than moving twice. maju116/rplatypus#241.

Added

Fixed

Documentation

No engine change and no pin move. platypus_spec() accepts a plain list of fields in data as well as one of the four constructors, and that route could describe one task out of four. maju116/rplatypus#239.

Fixed

Documentation

The pin moves to 0.8.0a49, and this is the first release this package could not take by moving the pin alone. platypus_spec() sends a YAML file or a bare data list straight to the engine's validator, so a pin at 0.8.0a49 with no constructors would have accepted task: classification from a file and refused it from classification_data() - one configuration with two answers, which is the divergence recorded for regions in 0.8.0a28. So the pin and the surface arrive together, and a test holds the two paths to the same specification.

classification_data() and classifier() are the two new constructors. classification_data() is as much the fields it has not got as the two it adds: colormap, labels, regions, classes_from, channels_from and target_spacing all describe a mask or a grid, and a label is neither. classifier() is the one model constructor in this package not named after an architecture - a classifier has nothing after the pooling but a dense layer, so the contracting path is the architecture and encoder is the whole answer.

Four metrics of their own, and the default is measured rather than chosen. metric_auc(), metric_accuracy(), metric_balanced_accuracy() and metric_f1() share no name with the mask metrics. auc is the engine's default because at a 3% prevalence a calibrated model never crosses the threshold: everything read off the hard prediction sees the same all-negative answer, so accuracy spans 0.0100 across models whose AUC spans 0.4631 and is not even monotone in their quality. class_balance() is the function that makes that prevalence visible before a run rather than after it, and it prints the engine's own remark instead of a copy of its threshold.

predict() answers per image and is joinable. type = "class" gives a row per image with key, label, predicted and probability; type = "probability" gives a matrix of images by classes with the keys as row names. Named rather than ordered, which is the lesson predict(space = "source") cost on the semantic side: a prediction handed back as a bare sequence has to be paired with whatever else the caller holds by position.

What is refused, by name, is as much of this as what is returned. space and the cases/each pair describe a grid and a split of masks too large to hold at once, and neither means anything for one label per image. evaluate(at = ) is refused for the same reason. evaluate_cases() is refused for a different one: a case here is one image whose score is 0 or 1, so a split's worst case is 0 for any model that gets a single image wrong - a number that carries nothing while reading like one that does.

Two defects found by doing this rather than by any test. The shim dropped the check_masks option for a detection specification by comparing the task against "detection", where the value is "object_detection" - so the branch never fired, the option reached an engine that has no such argument, and the TypeError was reported as this engine predates check_masks about an engine that is current. And predict()'s decode guard sat below the task branches, so a detection fit - the one branch that returns before reaching it - accepted decode and silently ignored it. Both are fixed here, because classification is the second task in the first position and would have inherited both.

?metrics also gains the prose it never had: it was A list of metrics, see [metrics]. against seven hundred characters in the engine, and the sentence it was missing is a mechanism - a callback watching val_<metric> is refused when nothing in the list will produce it.

The pin moves to 0.8.0a46, and what it brings is a measurement the engine could not make per object. ellipse_measure() fits an ellipse to a whole mask, which is what a fetal head circumference needs and not what a field of nuclei needs. The engine now has instance_shapes(): one row per object out of one label map, beside the rows instance_sizes() already gives, with the semi-axes largest first and - for an image - eccentricity, which is the number shape analysis reports.

instance_shapes() is on this side too, and it forwards - the way ellipse_measure() does and unlike instance_sizes(), which stays R's own arithmetic. That split is not a style choice: a count times a product needs no Python and a reader can verify it by eye, while an eigendecomposition per object cannot be reimplemented here without becoming a second implementation. The visible consequence is that this page's example is wrapped in \dontrun{} and instance_sizes()'s is not, because R CMD check runs examples and CRAN has no interpreter.

It is per object in one pass, which is the whole reason it exists rather than being a line in the documentation: calling ellipse_measure() on labels == k once per object rescans the whole array every time, and on a 512 by 512 map of 1939 objects that is 1989 ms against 143.

One thing to know before calling it through the bridge, because it is the trap and not a detail. eccentricity is unitless and it is not spacing-free: it describes whichever space it was computed in, so on anisotropic voxels the grid and the millimetres disagree completely, in both directions.

a circle of 80 px at spacing (2, 1)       grid 0.0000    mm 0.8660
an 80 by 40 px ellipse at spacing (1, 2)  grid 0.8667    mm 0.0685

The second row is physically almost a circle and the grid calls it strongly elongated. The engine says which space a row is in through the key names - major_px and minor_px without a spacing, major_mm and minor_mm with one - so pass your voxel size and read the _mm keys unless you mean the grid. platypus_device() is unaffected and so is everything else: this release adds a function and changes none.

A volume gets three axes and no eccentricity, because an ellipsoid has no single one.

A file is now read as a picture only if its contents are PNG, JPEG, TIFF, BMP or GIF. Nothing about how you name your files changes, and that is the point: the engine's reader identified a file by its contents and was handed nothing but a path, so a file carrying any other format - under any extension - went to whichever decoder its bytes asked for. One of those decoders hands the file to Ghostscript. A dataset of ordinary medical images is unaffected; a path holding something else is refused by name now, with a message saying that the extension was not what decided.

segmentation_data(), instance_data() and detection_data() all reach that reader, and so does anything that asks for a shape without reading pixels. Volumes, DICOM and DICOM series never went through it and are untouched.

This package now needs Python 3.11 or newer, and SystemRequirements says so. Python 3.10 reached end of life on 2026-10-01. For almost everybody this changes nothing: the package declares what it needs through reticulate and uv provisions the interpreter, so it simply provisions a newer one.

The exception is worth knowing before it happens. If you point RETICULATE_PYTHON at an interpreter of your own - the route this package warns about when the pyplatypus it finds there is not the pinned one, naming both versions - and that interpreter is 3.10, the engine can no longer be installed into it, and what you will see is a resolution failure from uv rather than anything this package wrote. The fix is a 3.11 interpreter, or letting py_require() choose one. platypus_device() is what reports the environment you ended up with.

Why the engine moved: its pillow floor was >= 10.0 while the decoder fixes it needs arrived in pillow 13, and pillow 13 requires Python 3.11 - so the floor could not be raised without dropping 3.10. Measured rather than assumed, nothing in the dependency tree moves forward with it: thirteen locked entries differ and every one is the removal of an old-Python branch.

A Python 3.10 user of the engine directly is served 0.8.0a43 by any installer rather than an error, which is the migration path and asks nothing of them. This package pins 0.8.0a44.

The numbers plot(fit) draws for a detection run change, and nothing you wrote changes with them. The engine averaged each epoch over batches, and for a detector that is not an average over examples: every component of the YOLO loss is divided inside the loss - three of them by a grid's object count and the fourth by the cells it supervises. A frame holding thirty blood cells therefore counted the same as a frame holding two.

An epoch is now pooled by what the loss divided by. maju116/rplatypus#218.

What this means for a run you already have:

It was measured before it was changed, on 150 instrumented epochs of the engine's own BCCD example: both poolings pick the same epoch, and that is reported as too close to call rather than as no effect - the gap between them varies across the best epochs by 1.05 times the margin separating the winner from the runner-up. The change is made because weighting a per-object quantity by batches is wrong, not because an epoch visibly moved.

available_weights() lists hc18-unet. A U-Net for the fetal skull in 2D ultrasound, trained on HC18 and published for measuring head circumference in millimetres rather than for an overlap score. Nothing in this package changed but the pin: the registry lives in the engine, so a new published name arrives in R through it.

available_weights()[["hc18-unet"]]
median absolute error     2.44 mm   against the sonographer's measurement, 199 images
mean                      3.84 mm   quoted beside the median because they disagree
worst                    48.07 mm
against the truth mask    3.41 mm   the model's own contribution, not the drawing
Dice                       0.9494   and the description says not to choose on it

Read that last line rather than the Dice. On this run the Dice and the millimetres order the same cases differently - Spearman 0.153 - and 85 of the 144 cases scoring 0.95 or better are still wrong by more than 2 mm. A ragged boundary barely moves an integral over a region and a mask that is systematically fat or thin moves it linearly, which are the two errors Dice is respectively most and least able to see, and they are the wrong way round for anybody measuring a length. These are the first published weights in this family where the score a reader reaches for first is the wrong one.

They are CC BY 4.0 rather than MIT, because HC18 is - the second set in the registry that is not MIT, after fives-unet, and attribution travels with them. The sidecar carries the citation and weights_licence.

Measuring with them from R is ellipse_measure(), which arrived in 0.8.0a39:

ellipse_measure(mask, spacing = c(px, px))

Predict on the source grid and measure there. The pixel size is a property of the file, so a prediction resized to the model's input shape has pixels of a size nobody recorded.

evaluate()'s figure no longer depends on batch_size. The engine divided an epoch's totals by the number of batches, so the examples of a short last batch counted more than the others and a split's reported score moved with a training setting. Measured on 199 validation cases: 2.3e-04 at batch_size 4 and 3.5e-03 at 32, and the size of the gap is not even predictable from the setting, because it depends on which cases land in the short batch.

It is a repair rather than a change of definition, which is why the numbers move at the fourth decimal rather than being restated: the per-batch value was already the mean over the examples in that batch, so a mean of means was reaching for the mean over examples and mis-weighting one batch of it.

The larger half is for tiled runs. evaluate() now pools a case's tiles by adding their overlap counts before scoring, the way summarise_cases() already did - so the table's figure is the mean over images rather than the mean over tiles. On the retinal-vessel runs that is worth +0.033 to +0.049 of Dice, and it is why the table in vignette("retinal-vessels") needed no editing: every number in it was computed from the per-case route already, and this brings the one-row table into agreement with it.

The figure is now exactly the mean of evaluate_cases()'s own rows, which is what anybody comparing the two expected and did not get.

Nothing here needs editing in a script. One consequence worth knowing: best_model() ranks on this table, so where two models sat within the shift of each other the winner can change - on the eleven-loss comparison the winner is unchanged and its margin grows from +0.0006 to +0.0033.

And a defect this did not fix, filed rather than folded in: the detection trainer divides by batches too, but every component of the YOLO loss is normalised by the number of objects in the batch, so averaging over batches weights a frame of thirty cells like a frame of two platelets. That is a different repair and it has its own issue, #218. No published detection number comes through it - the AP table is scored per image.

#196.

[ellipse_measure()] is new: the ellipse a segmented structure implies, as major, minor and circumference. It is what a sonographer writes down, and no overlap score stands in for it - two masks with the same Dice can be eight times apart in millimetres depending on whether they are wrong in a ring all the way round or wrong in a ragged edge.

class is an argument rather than an assumption, and it is the whole reason this is not a straight forward: the engine reads a foreground as anything above zero, which is right for its own argmax masks and silently wrong for this package's, where which.max writes 1 for background. Read that way the measured ellipse becomes the whole frame - 459.8 mm against 158.8 on the example in ?ellipse_measure - so mask == class happens here, before anything crosses.

And a decision worth more than the function: [mask_volume()] and [instance_sizes()] stay in R. The engine's 0.8.0a39 gained both, so forwarding them was the obvious move; it was written, measured and reverted.

It is faster - 0.035 s against 0.341 s on a 512 x 512 x 300 label map, because sum(mask == class) builds a logical vector of 78 million elements first - and what settled it was not speed. The engine's first call downloads about 5 GB, so a user holding a mask and a spacing would pay a torch install for a count times a product. R CMD check also runs examples and CRAN has no Python, so forwarding would have put mask_volume()'s page into \dontrun{}, on arithmetic a reader can verify by eye.

So the rule drawn, and both halves of this entry are on opposite sides of it: a measurement that is arithmetic stays here; a measurement that needs a library crosses. The ellipse wants connected components, hole filling and an eigendecomposition - reimplementing those would be a second implementation rather than a second copy of two lines.

tests/testthat/test-measure-pair.R holds both: R's answers against the engine's own on the same arrays, with a positive control, and a check that the functions with runnable examples do not reach the bridge - asked of their bodies, because calling them on a machine that has the engine proves nothing, which is exactly how the reverted version passed its gates.

One new condition kind travels with the pin: platypus_measure_error, for a spacing that does not fit the array it was given. ?platypus_conditions lists it, and nothing here noticed it was needed - test-conditions.R failed the moment the engine defined the kind, and named it.

maju116/pyplatypus#270.

platypus_device() behaves as it did, and the answer now comes from the engine rather than from this package. The report used to be computed in inst/python/platypus_shim.py - a file of Python shipped by this package - so a Python user could not reach any of it, which is backwards: the engine owns the decisions and this package reads them.

What stays here is the advice. The line telling you to call platypus_use_torch("pascal") is about R's session model and would mean nothing to somebody who installed the engine with pip; their version of that sentence belongs with the engine.

An accelerator that is not CUDA is now named. Until the engine's 0.8.0a38 it asked about CUDA alone, so an Apple Silicon Mac - every Mac sold since 2020 - reported cpu while a working mps backend sat in the same installation. platypus_device() prints the backend only when it is not CUDA, because every published set of weights was trained on a CUDA card and a score from another backend is not directly comparable with the registry's: the unremarkable case is not worth a line, and that one is.

maju116/pyplatypus#265 and #266.

callback_ema() keeps an exponential moving average of the weights, updated after every optimiser step. That is what makes its decay mean what the literature means: 0.999 is 0.999 per step, so over 60 epochs of 200 batches it is 12,000 updates and a half-life of about 700 steps. A per-epoch version would have been the same name with values that do not transfer, which is why the engine gained a fourth callback hook rather than an approximation.

It is [callback_swa()]'s sibling and a run takes one of them: both replace the model when training ends, so the engine refuses the pair rather than applying them in whichever order the list happened to have. swa folds the tail uniformly from a declared start; ema weights recent steps more and needs no start at all.

Unlike swa it needs no pass over the training data afterwards. A uniform average of weights carries batch-normalisation statistics belonging to no epoch and has to have them recomputed; an exponential average of the buffers alongside the weights carries its own.

maju116/rplatypus#203, engine half maju116/pyplatypus#264.

training_history() carries val_<metric>_worst: the lowest any single case scored, for the metrics whose counts pool into one score. monitor = "val_dice_worst" works, and so does a when condition on it.

This package has told every reader to look at that number since it had a summary() method - a model averaging 0.86 that scores 0.11 on some patient has a failure mode, and the mean is where it hides - and then every callback it offered watched a mean. Stopping, checkpointing and cutting the learning rate can all act on the distribution now.

Validation only. The training data is shuffled between epochs, so a case has no identity in that pass; "train_dice_worst" is refused rather than quietly reported as something else.

It costs 5.6% of an epoch, measured on the engine's side: the counts are computed in the validation pass that already happened rather than in a second one.

maju116/rplatypus#205, engine half maju116/pyplatypus#263.

Every epoch now reports foreground for both passes - the share of the prediction that is not background. Nothing is compared against the truth, so it is not a measure of quality: it answers whether the model is still producing a segmentation at all.

It arrives whether or not you ask for it, and that is the point. A model that collapses to predicting background everywhere is invisible in the numbers it already produced - with include_background = TRUE the Dice is a mean over class channels and the background channel is nearly perfect, so a collapsed model reports around 0.5 while its loss falls. A collapse you have to opt into noticing is one you will not notice.

callback_terminate_on_collapse() ends the run once that share has stayed at or below floor for patience epochs. A floor rather than a test for zero, because 0.01% foreground against a target that is 14% foreground has collapsed in every sense that matters; and a patience, because an early epoch legitimately predicts almost nothing. It can watch train_foreground instead, which answers a different question.

evaluate() carries the column, callback_model_checkpoint()'s when can take a condition on it, and any callback's monitor can watch it.

maju116/rplatypus#206, engine half maju116/pyplatypus#262.

Three things that answer one question, which is why they arrive together rather than as three unrelated arguments.

callback_early_stopping() takes a warm-up: start as a fraction of the run or start_epoch as an epoch, one of the two and never both. patience could not express it, because it is in force for the whole run - a model needing fifteen epochs before its validation curve means anything forced patience > 15 permanently, paying that slack at the end where the point was to cut quickly. It delays the stopping and nothing else: an epoch inside the warm-up can still be the best one, and restore_best still returns to it.

callback_swa() takes start_epoch as the exclusive alternative to its fraction - the same concept, so the same two words.

callback_model_checkpoint() takes when, a named list of thresholds that must all hold before anything is written: list(val_dice = 0.9, val_loss = 0.2). The direction is derived, not configured - anything ending in loss has to be below its threshold and everything else above it, the rule monitor already uses - so there is no argument to get backwards. The names are checked against what the model reports when the specification is built. It is an extra gate beside save_best_only, not a replacement, and a run whose conditions never hold writes nothing and says so when it ends.

callback_stop_after(hours = ) is a wall-clock budget. An epoch cap cannot express it: how long an epoch takes is a property of the data and the shape. It is checked at the end of an epoch, so the real limit is the budget plus one epoch, and the run's stop reason names the clock - a run that ran out of time does not read as one that converged.

maju116/rplatypus#199, #201 and #207; engine half maju116/pyplatypus#261.

callback_linear_annealing() is callback_cosine_annealing() with a straight line. callback_step_decay() multiplies the rate by factor at fixed points, which is how published recipes are written - divide by ten at epochs 30 and 60.

callback_step_decay() takes every or milestones, never both and never neither, and says so from R before the call reaches the engine. Two arguments rather than one that accepts either, because 30 and c(30) are different schedules and one argument holding either would be read as whichever was typed.

And a pairing this package has implied for as long as it has had a cosine does not work. A schedule recomputes the rate from the optimiser's initial value at the end of every epoch, so it erases whatever callback_reduce_lr_on_plateau() cut. Measured on six epochs of a loss that never improves, the rate with both configured follows the cosine exactly - 0.0976, 0.0905, 0.0794 from 0.1 - where the plateau alone reaches 1e-6. The engine refuses the pair now, by name, and so two schedules at once are refused as well.

A schedule beside callback_swa() is still fine, and no longer a matter of luck: the engine fixes the order so swa's constant tail rate wins whatever position the callbacks were written in.

maju116/rplatypus#200, engine half maju116/pyplatypus#260.

evaluate() and evaluate_cases() take at. Unset is what they have always done - each model scored on the grid it reads at. at = "source" puts every prediction back onto the grid of the file it came from and scores it against the truth as stored, so rows from models of different shapes answer one question and load_shape reads source.

This is the answer to a warning this package has printed since the engine's 0.8.0a30 and to a paragraph in vignette("retinal-vessels") that said there was no option for it. There was not: the measurement behind that section rescored its arms with a script that lived outside both packages. Measured there, scoring each model on its own grid reported a resized model at +0.0187 where the honest figure was +0.0033 - 5.7 times - and the bias runs the other way on clDice, because upsampling smooths a prediction.

The grid is the files' own and you cannot name another. A grid you name resamples the truth, so the thing both models are judged against would itself be an interpolation somebody chose. Only predictions move.

What it costs is what predict(space = "source") already documents: interpolation softens a boundary, and where reading cropped anatomy away the mask comes back padded with background - not examined rather than nothing there. For a comparison that is correct rather than unfair, but a 0 arriving that way is a result and not a fault. The loss is not reported on that grid, because a loss belongs to the objective a model trained with on the grid it trained on.

A detection fit refuses at by name rather than ignoring it: boxes are in the source image's coordinates already, so there is no grid to choose. The refusal is R's own and needs no engine, which is how its test runs on a machine with no Python.

maju116/rplatypus#187, engine half maju116/pyplatypus#259. The pooling question that came out of building it is maju116/rplatypus#196.

This file has a gap and it is named rather than backfilled. The entry below is dated 2026-10-08 and the pin moved twice between then and here - to 0.8.0a12 for fives-unet (#151) and to 0.8.0a20 for the instance task (#155) - so the dates above this heading are not a complete record of what the pin did. Writing those entries now would be writing them from the commit log rather than from the work, which is a different thing from having written them; the heading says what this change is and the omission stays visible.

Changed

Fixed

Added

Changed

Added

Changed

rplatypus 0.7.0

Changed, and it breaks code

Added

Fixed

rplatypus 0.6.0

Added

Fixed

rplatypus 0.4.0

rplatypus 0.3.0

The day the package stopped being a bridge and became a surface. Five engine releases, each followed here.

rplatypus 0.2.0

0.2.1 answered the review on its own branch, from the commit the submitted tarball was built from, because sending main would have replied to two documentation comments with ten new exported functions. The same two fixes belong here as well, or 0.3.0 collects the same review.

Description writes 'PyTorch' and 'Python' in single quotes, as the policy asks of software names in Title and Description. R stays unquoted: the cookbook the review links does not address it, and its own Title Case example is Web Application Framework for R.

colormaps.Rd gained the \value tag it never had. It documents four exported objects rather than functions, so it carried \format alone; the tag is written in the roxygen source and states structure and meaning, not just class.

Worth keeping because it is a property of the review rather than of this release, and the first version of this paragraph understated it. R CMD check --as-cran with the manual, on R 4.5.3, does not flag a missing \value - checking Rd contents says OK with the tag absent - and neither does tools:::checkRdContents(). That was written as a fact about this machine. It is a fact about both sides: CRAN's own automated pretest did not flag it either. Measured on the two logs, which are byte-comparable because the only difference between the submissions is this tag and two quoted names:

rplatypus 0.2.0, the version sent back    Windows 1 NOTE, Debian 1 NOTE
rplatypus 0.2.1, with the tag             Windows 1 NOTE, Debian 1 NOTE
mentions of `value` or `colormaps`        0 in all four logs

So the two submissions are indistinguishable to every check that runs, and the difference between them is exactly what the reviewer asked for. A clean pretest says nothing about whether the review will pass - a human read the .Rd files. The instrument that does ask the question is a check of our own: a page with \usage must have \value.

The package exists.

[0.1.1] - 2020-10-17

The last release of the TensorFlow package, kept here for the record rather than as an ancestor of the entries above. It built Keras models through the R keras package: yolo3() and darknet53() with load_darknet_weights() for the original Darknet weights, a plain u_net(), segmentation_generator(), loss_dice(), the COCO and VOC colormaps, and readers for LabelMe JSON and Pascal VOC XML.

It is still installable and still pinned:

remotes::install_github("maju116/[email protected]")

Its source is on the master branch and the issues filed against it stay open. What has aged is its dependencies rather than its code - the pinned TensorFlow no longer installs on a current Python, which is the reason for a rewrite rather than a consequence of one.

rplatypus 0.1.1

This was in the README until 2026-10-07 - seventy lines between ## Installing and the first example, about a version the reader does not have, before the one they do. It is here because this file is where R keeps what changed between versions, and because the renames table below was a second copy of one the README already carried; the fuller of the two survived.

Code written against the 2020 release will not run on this one. That version built Keras models through the R keras package and handed them back to you; this one describes a whole run as a specification and computes it in PyTorch. Of the 37 functions and objects the old version exported, 30 are gone — including all of yolo3(), darknet53(), load_darknet_weights(), get_boxes(), non_max_suppression(), plot_boxes(), segmentation_generator() and the annotation readers.

Seven names survive, and three of them are the ones to watch, because they are still exported, still callable, and mean something different:

| name | 0.1.1 | now | |---|---|---| | u_net() | u_net(net_h, net_w, grayscale, ...) → a Keras model | u_net(name, input_shape, ...) → one model in a specification | | plot_masks() | plot_masks(images_paths, masks, labels, colormap) | plot_masks(images, prediction, truth, ...) | | loss_dice() | no arguments, returns a Keras loss | loss_dice(smooth = 1), returns a loss to name in a specification |

The other four — binary_colormap, voc_colormap, binary_labels, voc_labels — are the same data as before.

Object detection is back, and the names are different. The old package handed you a Keras YOLOv3; this one describes a detector in a specification like everything else. So those five names are gone rather than moved, and what replaces them is:

| 0.1.1 | now | |---|---| | yolo3(), darknet53() | yolo3() as one entry in platypus_spec() - same name, a specification rather than a model | | load_darknet_weights() | weights = "bccd-yolo3", or a path, or hf://owner/repo/file@commit | | get_boxes(), non_max_suppression() | done inside predict(), which returns boxes already suppressed and in each image's own pixels | | plot_boxes() | plot_boxes() - same name, takes what predict() returns |

The annotation readers are gone as functions and are now what detection_data() reads for you: Pascal VOC XML or LabelMe JSON, named rather than guessed.

The old release is still there and still installable, pinned:

remotes::install_github("maju116/[email protected]")

Its source is on the master branch, and the issues filed against it stay open. What has aged is its dependencies rather than its code: the pinned TensorFlow no longer installs on a current Python, which is the reason for a rewrite rather than a consequence of one.